Adaptive Animated Underline Title with React and Motion
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 Animation
- SVG Animation
- Motion
- Adaptive UI
- Adaptive 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
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.
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:
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:
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:
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.
<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.
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:
<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:
'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 sitsResizeObserverkeeps the measurements up to date when the layout changes- SVG
strokeDashoffsetcreates the draw-in effect - Motion's variant propagation lets the parent control when the animation plays