Whispers of the Egg - Blaugust the Seventeenth
Are we out of time, Eye? I didn't start this on the 1st...
Some words breathe here when you hover or tab-focus them.
This post explains the how and the whyâespecially the accessibility bitsâso future-me (or you) can maintain it without swearing at past-me. Secrets on secrets.
The concept
- Idle: words are normal, zero glow.
- Interact: on hover or keyboard focus, letters breathe (subtle scale/spacing) and emit a glow based on
var(--accent-color)
. - Exit: a short âexhaleâ plays so it settles instead of snapping still.
No autoplay, no surprise motion. You have to touch it (mouse or keyboard) to wake it up.
Accessibility first
- No motion unless invited. The animation only runs on hover/focus.
- Respect user preferences. We fully disable animation under
prefers-reduced-motion: reduce
. - Keyboard parity. The same effect triggers on
:focus
(tabbing), and we keep a visible focus outline. - Screen readers. For the per-letter version, we wrap each character visually but expose the original, intact word via
aria-label
androle="text"
âscreen readers donât get spammed by 12 little spans.
The CSS
There are two flavors. Use eitherâor both.
A) Whole-word breathing (simpler)
Add the class to the word you want to animate:
<span class="gl-warp gl-breathe">void</span>
Core CSS (only runs on interaction; exhale on leave):
.gl-breathe {
display:inline-block;
outline-offset:.15em;
will-change: transform, text-shadow, letter-spacing;
text-shadow:none; /* idle: no glow */
}
/* active breathing */
.gl-breathe.is-breathing {
animation: gl-breathe 2600ms ease-in-out infinite;
}
/* one-shot exhale on leave */
.gl-breathe.is-exhale {
animation: gl-exhale 420ms ease-out 1;
}
@keyframes gl-breathe {
0%,100% { transform: scale(1); letter-spacing: 0; text-shadow:none; }
50% {
transform: scale(1.04);
letter-spacing: 0.02em;
text-shadow:
0 0 14px color-mix(in oklab, var(--accent-color) 90%, transparent),
0 0 24px color-mix(in oklab, var(--accent-color) 65%, transparent);
}
}
@keyframes gl-exhale {
0% {
transform: scale(1.02);
letter-spacing: 0.012em;
text-shadow:
0 0 10px color-mix(in oklab, var(--accent-color) 70%, transparent),
0 0 16px color-mix(in oklab, var(--accent-color) 45%, transparent);
}
100% { transform: scale(1); letter-spacing: 0; text-shadow:none; }
}
.gl-breathe:focus {
outline: 2px solid color-mix(in oklab, var(--accent-color) 60%, currentColor);
}
@media (prefers-reduced-motion: reduce) {
.gl-breathe,
.gl-breathe.is-breathing,
.gl-breathe.is-exhale {
animation:none !important; transform:none; letter-spacing:0; text-shadow:none;
}
}
B) Per-letter breathing (more organic)
Same interaction model, but each letter pulses with a small stagger.
HTML stays simple:
<span class="gl-warp gl-breathe-letters">whisper</span>
CSS:
.gl-breathe-letters .gl-char {
display:inline-block;
will-change: transform, text-shadow, letter-spacing;
text-shadow:none; /* idle */
}
/* active breathing */
.gl-breathe-letters.is-breathing .gl-char {
animation: gl-breathe-letter 2600ms ease-in-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
/* exhale */
.gl-breathe-letters.is-exhale .gl-char {
animation: gl-exhale-letter 420ms ease-out 1;
animation-delay: calc(var(--i) * 30ms);
}
@keyframes gl-breathe-letter {
0%,100% { transform: translateY(0) scale(1); letter-spacing:0; text-shadow:none; }
50% {
transform: translateY(-0.06em) scale(1.03);
letter-spacing:0.015em;
text-shadow:
0 0 14px color-mix(in oklab, var(--accent-color) 95%, transparent),
0 0 22px color-mix(in oklab, var(--accent-color) 70%, transparent);
}
}
@keyframes gl-exhale-letter {
0% {
transform: translateY(-0.02em) scale(1.015);
letter-spacing: 0.006em;
text-shadow:
0 0 10px color-mix(in oklab, var(--accent-color) 70%, transparent),
0 0 16px color-mix(in oklab, var(--accent-color) 45%, transparent);
}
100% { transform: translateY(0) scale(1); letter-spacing: 0; text-shadow:none; }
}
.gl-breathe-letters:focus {
outline:2px solid color-mix(in oklab, var(--accent-color) 60%, currentColor);
outline-offset:.12em;
}
@media (prefers-reduced-motion: reduce) {
.gl-breathe-letters .gl-char { animation:none !important; transform:none; text-shadow:none; }
}
The JavaScript
One helper does three things:
- Wraps each letter for the per-letter effect (and hides those spans from screen readers).
- Toggles
.is-breathing
on mouseenter/focusin. - Toggles a one-shot
.is-exhale
on mouseleave/focusout, then removes it onanimationend
.
<script>
(function(){
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Wrap letters for .gl-breathe-letters
document.querySelectorAll('.gl-breathe-letters').forEach(el => {
if (el.dataset.glWrapped === '1') return;
const text = el.textContent;
el.setAttribute('aria-label', text);
el.setAttribute('role', 'text');
el.textContent = '';
Array.from(text).forEach((ch, i) => {
const span = document.createElement('span');
span.className = 'gl-char';
span.style.setProperty('--i', i);
span.setAttribute('aria-hidden', 'true');
span.textContent = ch;
el.appendChild(span);
});
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
el.dataset.glWrapped = '1';
});
// Wire hover/focus behavior for both flavors
function wireBreath(selector){
document.querySelectorAll(selector).forEach(el=>{
const start = () => {
if (reduceMotion) return;
el.classList.remove('is-exhale');
el.classList.remove('is-breathing');
void el.offsetWidth; // restart if needed
el.classList.add('is-breathing');
};
const end = () => {
if (reduceMotion) return;
el.classList.remove('is-breathing');
el.classList.add('is-exhale');
};
const cleanup = (e) => {
if (e.animationName.startsWith('gl-exhale')) el.classList.remove('is-exhale');
};
el.addEventListener('mouseenter', start);
el.addEventListener('focusin', start);
el.addEventListener('mouseleave', end);
el.addEventListener('focusout', end);
el.addEventListener('animationend', cleanup);
});
}
wireBreath('.gl-breathe'); // whole-word
wireBreath('.gl-breathe-letters'); // per-letter
})();
</script>
Plain-language summary
What it is: A tiny script + CSS that makes selected words âbreatheâ (subtle swell + glow) only when hovered or focused, then exhale and go still.
How it treats people well: It respects reduced-motion settings, works with keyboard, and doesnât spam screen readers.
Where to tweak: Change animation durations, glow strength (text-shadow
), and per-letter stagger in CSS. Add/remove the .gl-breathe
/ .gl-breathe-letters
classes to choose which words can breathe.
Last edited: 6Â hours, 48Â minutes ago.