Breathe
import { useEffect, useRef, useState } from 'react';
// One breath: in, hold, out. Two rounds, then it rests again.
const STEPS = [
{ word: 'Breathe in', ms: 4000, phase: 'in' },
{ word: 'Hold', ms: 2000, phase: 'hold' },
{ word: 'Breathe out', ms: 4000, phase: 'out' },
];
const ROUNDS = 2;
const css = `
@keyframes btn-breathe-idle { 50% { transform: scale(1.08); opacity: 0.55; } }
.btn-breathe { position: relative; display: inline-grid; place-items: center; width: 150px; height: 150px; }
.btn-breathe__halo {
position: absolute;
inset: 18px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, light-dark(#50535b, #c6c8cd) 30%, transparent), transparent 70%);
box-shadow: 0 0 0 1px color-mix(in oklab, light-dark(#5f626a, #a7abb2) 30%, transparent);
opacity: 0.35;
animation: btn-breathe-idle 6s ease-in-out infinite;
}
.btn-breathe__halo--2 { inset: 34px; animation-delay: -1.5s; }
.btn-breathe[data-phase] .btn-breathe__halo { animation: none; transition: transform var(--ms) cubic-bezier(0.45, 0, 0.55, 1), opacity var(--ms); }
.btn-breathe[data-phase='in'] .btn-breathe__halo, .btn-breathe[data-phase='hold'] .btn-breathe__halo { transform: scale(1.22); opacity: 0.8; }
.btn-breathe[data-phase='out'] .btn-breathe__halo { transform: scale(0.8); opacity: 0.3; }
.btn-breathe__btn {
position: relative;
display: grid;
place-items: center;
width: 92px;
height: 92px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#ebedf0, #232529);
box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41), 0 10px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
}
.btn-breathe__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.btn-breathe__word { transition: opacity 0.4s; }
.btn-breathe__sub { display: block; margin-top: 2px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 400; }
@media (prefers-reduced-motion: reduce) {
.btn-breathe__halo { animation: none; transition: none !important; }
}
`;
export function BreatheButton() {
const [step, setStep] = useState(-1);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (step < 0) return;
if (step >= STEPS.length * ROUNDS) {
setStep(-1);
return;
}
timer.current = setTimeout(() => setStep((s) => s + 1), STEPS[step % STEPS.length]!.ms);
return () => clearTimeout(timer.current);
}, [step]);
const cur = step >= 0 ? STEPS[step % STEPS.length]! : null;
return (
<>
<style>
{css}
</style>
<div className="btn-breathe" data-phase={cur?.phase} style={{ '--ms': `${cur?.ms ?? 0}ms` } as React.CSSProperties}>
<span className="btn-breathe__halo" aria-hidden="true" />
<span className="btn-breathe__halo btn-breathe__halo--2" aria-hidden="true" />
<button type="button" className="btn-breathe__btn" aria-pressed={!!cur} onClick={() => setStep((s) => (s < 0 ? 0 : -1))}>
<span className="btn-breathe__word" aria-live="polite">
{cur ? cur.word : 'Begin'}
<span className="btn-breathe__sub">{cur ? `Round ${Math.floor(step / STEPS.length) + 1} of ${ROUNDS}` : 'One minute'}</span>
</span>
</button>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnBreathe(el) on the mounted element instead.
<div class="btn-breathe" style="--ms:0ms">
<span class="btn-breathe__halo" aria-hidden="true"></span>
<span class="btn-breathe__halo btn-breathe__halo--2" aria-hidden="true"></span>
<button type="button" class="btn-breathe__btn" aria-pressed="false">
<span class="btn-breathe__word" aria-live="polite">Begin<span class="btn-breathe__sub">One minute</span></span>
</button>
</div>@keyframes btn-breathe-idle { 50% { transform: scale(1.08); opacity: 0.55; } }
.btn-breathe { position: relative; display: inline-grid; place-items: center; width: 150px; height: 150px; }
.btn-breathe__halo {
position: absolute;
inset: 18px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, light-dark(#50535b, #c6c8cd) 30%, transparent), transparent 70%);
box-shadow: 0 0 0 1px color-mix(in oklab, light-dark(#5f626a, #a7abb2) 30%, transparent);
opacity: 0.35;
animation: btn-breathe-idle 6s ease-in-out infinite;
}
.btn-breathe__halo--2 { inset: 34px; animation-delay: -1.5s; }
.btn-breathe[data-phase] .btn-breathe__halo { animation: none; transition: transform var(--ms) cubic-bezier(0.45, 0, 0.55, 1), opacity var(--ms); }
.btn-breathe[data-phase='in'] .btn-breathe__halo, .btn-breathe[data-phase='hold'] .btn-breathe__halo { transform: scale(1.22); opacity: 0.8; }
.btn-breathe[data-phase='out'] .btn-breathe__halo { transform: scale(0.8); opacity: 0.3; }
.btn-breathe__btn {
position: relative;
display: grid;
place-items: center;
width: 92px;
height: 92px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#ebedf0, #232529);
box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41), 0 10px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
}
.btn-breathe__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.btn-breathe__word { transition: opacity 0.4s; }
.btn-breathe__sub { display: block; margin-top: 2px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 400; }
@media (prefers-reduced-motion: reduce) {
.btn-breathe__halo { animation: none; transition: none !important; }
}function initBtnBreathe(root) {
// One breath: in, hold, out. Two rounds, then it rests again.
var STEPS = [
{ word: 'Breathe in', ms: 4000, phase: 'in' },
{ word: 'Hold', ms: 2000, phase: 'hold' },
{ word: 'Breathe out', ms: 4000, phase: 'out' },
];
var ROUNDS = 2;
var btn = root.querySelector('.btn-breathe__btn');
var word = root.querySelector('.btn-breathe__word').firstChild;
var sub = root.querySelector('.btn-breathe__sub');
var step = -1;
var timer;
function render() {
var cur = step >= 0 ? STEPS[step % STEPS.length] : null;
if (cur) root.setAttribute('data-phase', cur.phase);
else root.removeAttribute('data-phase');
root.style.setProperty('--ms', (cur ? cur.ms : 0) + 'ms');
btn.setAttribute('aria-pressed', String(!!cur));
word.nodeValue = cur ? cur.word : 'Begin';
sub.textContent = cur ? 'Round ' + (Math.floor(step / STEPS.length) + 1) + ' of ' + ROUNDS : 'One minute';
}
function go(s) {
clearTimeout(timer);
step = s >= STEPS.length * ROUNDS ? -1 : s;
render();
if (step >= 0) timer = setTimeout(function () { go(step + 1); }, STEPS[step % STEPS.length].ms);
}
function onClick() { go(step < 0 ? 0 : -1); }
btn.addEventListener('click', onClick);
return function () {
clearTimeout(timer);
btn.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-breathe').forEach(initBtnBreathe);Goal: Create a "Breathe" button.
Visual behavior: A calm breathing session button: at rest its halo breathes slowly; press it and it guides you through breathing in, holding and breathing out for two rounds, the halo swelling and shrinking with each step.
Technique:
- A timer walks through the steps (in 4s, hold 2s, out 4s, twice); each step sets data-phase and its duration as --ms on the root.
- The halos transition their scale and opacity over --ms, so they swell for exactly as long as the breath in and shrink over the breath out; at rest a slow idle keyframe takes over.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. It is a toggle (aria-pressed) and the instruction is aria-live="polite", so each step is read out; press again to stop. Under prefers-reduced-motion the halos stay still and only the words guide you.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Breathe" button.
Visual behavior: A calm breathing session button: at rest its halo breathes slowly; press it and it guides you through breathing in, holding and breathing out for two rounds, the halo swelling and shrinking with each step.
Technique:
- A timer walks through the steps (in 4s, hold 2s, out 4s, twice); each step sets data-phase and its duration as --ms on the root.
- The halos transition their scale and opacity over --ms, so they swell for exactly as long as the breath in and shrink over the breath out; at rest a slow idle keyframe takes over.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. It is a toggle (aria-pressed) and the instruction is aria-live="polite", so each step is read out; press again to stop. Under prefers-reduced-motion the halos stay still and only the words guide you.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A calm breathing session button: at rest its halo breathes slowly; press it and it guides you through breathing in, holding and breathing out for two rounds, the halo swelling and shrinking with each step.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, ambient, glow
- Techniques
- css variables, css transition, keyframes, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. It is a toggle (aria-pressed) and the instruction is aria-live="polite", so each step is read out; press again to stop. Under prefers-reduced-motion the halos stay still and only the words guide you.