Underline Wipe

hover

Hover or focus to wipe an accent underline in from the left.

Hover the text
17/120
Align
Ease
Duration
s
0.13
Stagger
s
00.2
Delay
s
02
export function underlineWipe(el) {
  const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  const _duration = _reducedMotion ? 0.01 : 0.3

  el.style.position = 'relative'
  el.style.display = 'inline-block'
  const line = document.createElement('span')
  line.className = 'k-underline'
  line.setAttribute('aria-hidden', 'true')
  el.append(line)
  const easing = 'linear(0.0000,0.0987,0.1899,0.2740,0.3513,0.4224,0.4874,0.5467,0.6008,0.6499,0.6943,0.7343,0.7703,0.8025,0.8311,0.8566,0.8790,0.8987,0.9159,0.9308,0.9437,0.9546,0.9639,0.9717,0.9781,0.9834,0.9877,0.9910,0.9937,0.9957,0.9972,0.9982,0.9990,0.9994,0.9997,0.9999,1.0000,1.0000,1.0000,1.0000)'
  let current = null
  const animateTo = (scaleX) => {
    const from = getComputedStyle(line).transform
    current?.cancel()
    current = line.animate([{ transform: from }, { transform: `scaleX(${scaleX})` }], {
      duration: _duration * 1000,
      easing,
      fill: 'forwards',
    })
  }
  const enter = () => animateTo(1)
  const leave = () => animateTo(0)
  const ons = [['pointerenter', enter], ['pointerleave', leave], ['focus', enter], ['blur', leave]]
  ons.forEach(([e, fn]) => el.addEventListener(e, fn))
  return () => {
    current?.cancel()
    ons.forEach(([e, fn]) => el.removeEventListener(e, fn))
    line.remove()
    el.style.position = ''
    el.style.display = ''
  }
}

/* CSS */
.k-underline {
  position: absolute;
  inset-inline-start: 0;
  inset-block-end: -2px;
  inline-size: 100%;
  block-size: 2px;
  background: var(--accent);
  transform-origin: left;
  transform: scaleX(0);
}