DBHow 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 example ☝️
The component does three things:
Let's go through each part.
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.
Each rectangle has position and size values. We convert them from viewport coordinates to coordinates relative to the heading element:
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.
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:
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.
Each underline is an SVG with a single path element. The path uses cubic bezier curves to create a wave shape:
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.
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.
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.
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:
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.
Here is the complete source code:
The StaggeredUnderlineTitle component combines a few browser APIs with Motion's animation system:
Range.getClientRects() finds where each line of wrapped text sitsResizeObserver keeps the measurements up to date when the layout changesstrokeDashoffset creates the draw-in effectconst range = document.createRange()
range.selectNodeContents(textNode)
const rects = range.getClientRects()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,
}))useLayoutEffect(() => {
const observer = new ResizeObserver(measure)
observer.observe(heading)
document.fonts.ready.then(measure)
return () => observer.disconnect()
}, [text])const WAVY_PATH = 'M3 6C45 2 95 9 145 6C195 3 245 8 297 5'<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>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 },
},
}
}<m.div initial="rest" whileHover="hover" animate="rest">
<StaggeredUnderlineTitle text={title} />
</m.div>'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>
)
}