github.com/angrybunnyman.com

Portrait of the Man as a...

Whispers of the Egg - Blaugust the Seventeenth

Portrait of a Egg

Post Nutrition Label

  • Content Type: Text
  • Read Time: 10 min
  • Topics: HTML, CSS, JS
  • Mood: Devious

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

No autoplay, no surprise motion. You have to touch it (mouse or keyboard) to wake it up.

Accessibility first

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:

  1. Wraps each letter for the per-letter effect (and hides those spans from screen readers).
  2. Toggles .is-breathing on mouseenter/focusin.
  3. Toggles a one-shot .is-exhale on mouseleave/focusout, then removes it on animationend.
<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.

Reply on Bluesky ⤤ Reply by Email (or just say hi!) Reply on Mastodon ⤤

Last edited: 6 hours, 48 minutes ago.

#blaugust