dmdm
O mneProjektyBlogKontakty
Napíšme siNapíšme si

dmdm

Softvérový inžinier & Vercel nadšenec. Tvorím rýchle, reaktívne webové zážitky.

Stránky

  • O mne
  • Projekty
  • Blog
  • Kontakty

Prepojenie

  • LinkedIn
  • GitHub
  • Twitter/X
  • Email

Viac

  • RSS kanál
  • Mapa stránok
© 2026 Dmytro Bobryshev. Všetky práva vyhradené.Naposledy aktualizoval Dmytro dňa 23. 6. 2026, 17:07
Späť na blog
Underline cover

Adaptive Animated Underline Title with React and Motion

Dmytro Bobryshev26 máj 2026•3 min čítania

How to build a React component that draws wavy underlines below text on hover. We measure text lines with the browser's Range API and animate SVG paths with Motion.

Underline AnimationSVG AnimationMotionAdaptive UIAdaptive underline

A title component that adds wavy underlines below each line of text. When you hover over it, the underlines draw in one by one. The component works with any text length and updates when the window resizes.

Building great software takes patience, craft, and attention to detail

Interactive example ☝️

How it works

The component does three things:

  • Measures each line of text to know where to place the underlines
  • Creates an SVG path for each line with a wavy shape
  • Animates the paths using Motion's variant system

Let's go through each part.

Measuring text lines

The trickiest part is figuring out where each line of text starts and ends. Browsers wrap text on their own, and there is no simple way to ask "how many lines does this text have?"

We use the Range API to solve this. When you call getClientRects() on a Range, the browser gives you one rectangle per visual line of text.

staggered-underline-title.tsx
const range = document.createRange()
range.selectNodeContents(textNode)
const rects = range.getClientRects()

Each rectangle has position and size values. We convert them from viewport coordinates to coordinates relative to the heading element:

staggered-underline-title.tsx
const parentRect = el.getBoundingClientRect()

const lines = [...range.getClientRects()].map((rect) => ({
  left: (rect.left - parentRect.left) / scale,
  top: (rect.bottom - parentRect.top) / scale,
  width: rect.width / scale,
}))

The scale variable handles CSS transforms. If the parent has a transform applied, getBoundingClientRect() returns the transformed size, but offsetWidth returns the original size. We divide by their ratio to get the right coordinates.

Responding to size changes

Text can reflow when the window resizes or when a web font finishes loading. We use ResizeObserver to re-measure whenever the heading changes size:

staggered-underline-title.tsx
useLayoutEffect(() => {
  const observer = new ResizeObserver(measure)
  observer.observe(heading)
  document.fonts.ready.then(measure)

  return () => observer.disconnect()
}, [text])

We also call measure after fonts load. Web fonts can change text width after the first render, so the underlines need to update their positions.

The wavy SVG path

Each underline is an SVG with a single path element. The path uses cubic bezier curves to create a wave shape:

staggered-underline-title.tsx
const WAVY_PATH = 'M3 6C45 2 95 9 145 6C195 3 245 8 297 5'

The SVG uses a fixed viewBox of 0 0 300 10 and preserveAspectRatio="none". This stretches the wave to match the text width. The wave frequency changes with the text length, which looks natural.

staggered-underline-title.tsx
<svg
  viewBox="0 0 300 10"
  preserveAspectRatio="none"
  className="pointer-events-none absolute h-2 overflow-visible"
  style={{ left: line.left, top: line.top, width: line.width }}
>
  <m.path
    d={WAVY_PATH}
    className="fill-none stroke-primary"
    strokeWidth={3}
    strokeDasharray={400}
    variants={getLineVariants(index, lastIndex)}
  />
</svg>

The draw-in animation

The animation uses a common SVG trick with strokeDasharray and strokeDashoffset. We set strokeDasharray to a large number (400) and animate strokeDashoffset from 400 to 0. This makes the path look like it is drawing itself.

staggered-underline-title.tsx
function getLineVariants(index: number, lastIndex: number): Variants {
  const ease = [0.22, 1, 0.36, 1] as const
  const draw = { duration: 0.7, ease }
  return {
    rest: {
      strokeDashoffset: 400,
      transition: { ...draw, delay: (lastIndex - index) * LINE_STAGGER_S },
    },
    hover: {
      strokeDashoffset: 0,
      transition: { ...draw, delay: index * LINE_STAGGER_S },
    },
  }
}

The stagger works in opposite directions. On hover, the first line draws in first. On mouse leave, the last line erases first. This creates a smooth wave-like flow.

Variant propagation

The component itself does not have whileHover. Instead, it defines rest and hover variants on its motion elements. The hover trigger comes from a parent component.

In the project cards, the parent wraps everything like this:

staggered-underline-title.tsx
<m.div initial="rest" whileHover="hover" animate="rest">
  <StaggeredUnderlineTitle text={title} />
</m.div>

Motion sends variant changes down to all child motion components automatically. When the parent switches to "hover", every motion element inside transitions to its hover variant.

This keeps the component flexible. It does not care about how the hover happens — the parent decides.

The full component

Here is the complete source code:

staggered-underline-title.tsx
'use client'

import type { Variants } from 'motion/react'
import * as m from 'motion/react-m'
import { useLayoutEffect, useRef, useState } from 'react'

import { SPRING } from '@/constants/animations'
import { cn } from '@/lib/utils'

const LINE_STAGGER_S = 0.15
const WAVY_PATH = 'M3 6C45 2 95 9 145 6C195 3 245 8 297 5'

function getLineVariants(index: number, lastIndex: number): Variants {
  const ease = [0.22, 1, 0.36, 1] as const
  const draw = { duration: 0.7, ease }
  return {
    rest: {
      strokeDashoffset: 400,
      transition: { ...draw, delay: (lastIndex - index) * LINE_STAGGER_S },
    },
    hover: {
      strokeDashoffset: 0,
      transition: { ...draw, delay: index * LINE_STAGGER_S },
    },
  }
}

type Line = {
  left: number
  top: number
  width: number
}

type Props = {
  text: string
  className?: string
}

export function StaggeredUnderlineTitle(props: Props) {
  const { text, className } = props

  const ref = useRef<HTMLHeadingElement>(null)
  const [lines, setLines] = useState<Line[]>([])

  useLayoutEffect(() => {
    if (!text) return

    const heading = ref.current
    if (!heading) return

    function measure() {
      const el = heading
      if (!el) return

      const textNode = el.firstChild
      if (!textNode) return

      const range = document.createRange()
      range.selectNodeContents(textNode)
      const parentRect = el.getBoundingClientRect()
      const scale = el.offsetWidth > 0 ? parentRect.width / el.offsetWidth : 1

      setLines(
        [...range.getClientRects()].map((rect) => ({
          left: (rect.left - parentRect.left) / scale,
          top: (rect.bottom - parentRect.top) / scale,
          width: rect.width / scale,
        })),
      )
    }

    const observer = new ResizeObserver(measure)
    observer.observe(heading)
    document.fonts.ready.then(measure)

    return () => observer.disconnect()
  }, [text])

  const lastIndex = lines.length - 1

  return (
    <m.h3
      ref={ref}
      className={cn(
        'relative max-w-full text-lg font-semibold tracking-tight',
        className,
      )}
      variants={{ rest: { x: 0 }, hover: { x: 3 } }}
      transition={SPRING}
    >
      {text}
      {lines.map((line, index) => (
        <svg
          key={`${line.left}-${line.top}-${line.width}`}
          viewBox="0 0 300 10"
          preserveAspectRatio="none"
          aria-hidden="true"
          className="pointer-events-none absolute h-2 overflow-visible"
          style={{ left: line.left, top: line.top, width: line.width }}
        >
          <m.path
            d={WAVY_PATH}
            className="fill-none stroke-primary"
            strokeWidth={3}
            strokeDasharray={400}
            variants={getLineVariants(index, lastIndex)}
          />
        </svg>
      ))}
    </m.h3>
  )
}

Summary

The StaggeredUnderlineTitle component combines a few browser APIs with Motion's animation system:

  • Range.getClientRects() finds where each line of wrapped text sits
  • ResizeObserver keeps the measurements up to date when the layout changes
  • SVG strokeDashoffset creates the draw-in effect
  • Motion's variant propagation lets the parent control when the animation plays
Páčil sa Vám tento článok?

Začnime spolupracovať!

Máte na mysli projekt alebo sa chcete len pozdraviť? Som vždy otvorený novým nápadom a spoluprácam.

Kontaktujte maKontaktujte maZobraziť projektyZobraziť projekty
PredchádzajúciBuilding a Dark Mode Toggle with a Circular Wipe Animation in ReactĎalšíComplex to Simple: DigitalOcean Spaces