Char Ripple

hover

Hover or focus to send an endless ripple through the characters.

I Love Bangladesh

Hover the text
17/120
Align
Ease
Duration
s
0.13
Stagger
s
00.2
Delay
s
02
export function charRipple(el) {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}

  const split = splitChars(el)
  const easing = 'linear(0.0000,0.0016,0.0065,0.0145,0.0257,0.0400,0.0573,0.0774,0.1003,0.1257,0.1536,0.1838,0.2160,0.2500,0.2857,0.3227,0.3609,0.4000,0.4397,0.4799,0.5201,0.5603,0.6000,0.6391,0.6773,0.7143,0.7500,0.7840,0.8162,0.8464,0.8743,0.8997,0.9226,0.9427,0.9600,0.9743,0.9855,0.9935,0.9984,1.0000)'
  let anims = []
  const enter = () => {
    anims.forEach((a) => a.cancel())
    anims = split.chars.map((c, i) =>
      c.animate([{ transform: 'none' }, { transform: 'translateY(-8px)' }], {
        duration: 0.45 * 1000,
        delay: i * 0.035 * 1000,
        easing,
        iterations: Infinity,
        direction: 'alternate',
        fill: 'backwards',
      }),
    )
  }
  const leave = () => {
    const froms = split.chars.map((c) => getComputedStyle(c).transform)
    anims.forEach((a) => a.cancel())
    anims = split.chars.map((c, i) =>
      c.animate([{ transform: froms[i] }, { transform: 'none' }], {
        duration: 0.45 * 1000,
        easing,
        fill: 'forwards',
      }),
    )
  }
  const ons = [['pointerenter', enter], ['pointerleave', leave], ['focus', enter], ['blur', leave]]
  ons.forEach(([e, fn]) => el.addEventListener(e, fn))
  return () => {
    anims.forEach((a) => a.cancel())
    ons.forEach(([e, fn]) => el.removeEventListener(e, fn))
    split.revert()
  }
}

function splitChars(el) {
  const text = el.textContent ?? ''

  if (el.children.length > 0) {
    return { chars: [el], words: [el], revert: () => {} }
  }

  const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

  const segmenter =
    typeof Intl !== 'undefined' && 'Segmenter' in Intl
      ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
      : null
  const toChars = (s) => (segmenter ? Array.from(segmenter.segment(s), (seg) => seg.segment) : Array.from(s))

  const tokens = text.match(/\S+|\s+/g) ?? []
  el.setAttribute('aria-label', text)
  el.innerHTML = tokens
    .map((token) => {
      if (/^\s+$/.test(token)) return token
      const chars = toChars(token)
        .map((c) => `<span class="k-char">${escape(c)}</span>`)
        .join('')
      return `<span class="k-word" aria-hidden="true">${chars}</span>`
    })
    .join('')

  return {
    chars: [...el.querySelectorAll('.k-char')],
    words: [...el.querySelectorAll('.k-word')],
    revert: () => {
      el.textContent = text
      el.removeAttribute('aria-label')
    },
  }
}

/* CSS */
.k-word {
  display: inline-block;
  white-space: nowrap;
}

.k-char {
  display: inline-block;
}