# Adaptive Animated Underline Title with React and Motion

*Dmytro Bobryshev · 26 máj 2026*

> 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.

---

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.

> **Interactive Demo** — staggered-underline: 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.

```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:

```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:

```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:

```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.

```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.

```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:

```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:

```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