A hand-drawn scribble, swoosh, double line, circle or strike-through traces itself around your words.
Ship it today
Customize
Usage
import { UnderlineDraw } from "@/components/motionkit/underline-draw";
<h2>
Ship it <UnderlineDraw>today</UnderlineDraw>
</h2>Motion
The exact curves this component runs, plotted with Motion's own maths.
Line draw
Scrolls into view
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/underline-draw.tsx.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type Variant = "scribble" | "swoosh" | "double" | "circle" | "strike";
type UnderlineDrawProps = {
children: ReactNode;
/** The mark drawn around the words. */
variant?: Variant;
color?: string;
/** Line width in pixels. */
thickness?: number;
/** Seconds to draw the mark. */
duration?: number;
/** Seconds to wait once in view. */
delay?: number;
className?: string;
};
/*
* Each mark is built in real pixels for the measured box, so the stroke keeps an even
* width at any size and the line-draw (pathLength) stays exact.
*/
function markPath(variant: Variant, w: number, h: number) {
switch (variant) {
case "scribble":
return `M 2 ${h * 0.55} C ${w * 0.2} ${h * 0.15}, ${w * 0.35} ${h * 0.95}, ${w * 0.55} ${h * 0.5} S ${w * 0.85} ${h * 0.2}, ${w - 2} ${h * 0.6}`;
case "swoosh":
return `M 2 ${h * 0.8} Q ${w * 0.45} ${h * 0.05}, ${w - 2} ${h * 0.45}`;
case "double":
return `M 2 ${h * 0.3} Q ${w * 0.5} ${h * 0.05}, ${w - 2} ${h * 0.3} M ${w * 0.06} ${h * 0.82} Q ${w * 0.5} ${h * 0.55}, ${w - 6} ${h * 0.8}`;
case "strike":
return `M 0 ${h * 0.55} Q ${w * 0.5} ${h * 0.4}, ${w} ${h * 0.5}`;
case "circle":
// a loop that starts top left, goes round once and overshoots its start, as a pen would
return `M ${w * 0.18} ${h * 0.12} C ${w * 0.55} ${-h * 0.02}, ${w * 0.98} ${h * 0.08}, ${w * 0.99} ${h * 0.48} C ${w} ${h * 0.92}, ${w * 0.45} ${h * 1.02}, ${w * 0.12} ${h * 0.86} C ${-w * 0.04} ${h * 0.74}, ${w * 0.01} ${h * 0.22}, ${w * 0.32} ${h * 0.08}`;
}
}
// where the mark sits relative to the words
const placement: Record<Variant, string> = {
scribble: "left-0 right-0 top-[88%] h-[0.38em]",
swoosh: "left-0 right-0 top-[86%] h-[0.4em]",
double: "left-0 right-0 top-[88%] h-[0.36em]",
strike: "left-[-0.08em] right-[-0.08em] top-[30%] h-[0.4em]",
circle: "left-[-0.35em] right-[-0.35em] top-[-0.2em] bottom-[-0.15em]",
};
export function UnderlineDraw({
children,
variant = "scribble",
color = "#5FAEB4",
thickness = 3,
duration = 0.7,
delay = 0,
className = "",
}: UnderlineDrawProps) {
const ref = useRef<HTMLSpanElement>(null);
const markRef = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: "all" });
const reduceMotion = useReducedMotion();
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
const mark = markRef.current;
if (!mark) return;
const observer = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }));
observer.observe(mark);
return () => observer.disconnect();
}, []);
const drawn = inView || !!reduceMotion;
return (
<span ref={ref} className={`relative inline-block whitespace-nowrap ${className}`}>
{children}
<span ref={markRef} aria-hidden className={`pointer-events-none absolute ${placement[variant]}`}>
{size && size.w > 0 && (
<svg width={size.w} height={size.h} viewBox={`0 0 ${size.w} ${size.h}`} className="absolute inset-0 overflow-visible">
{/* the group hides the round cap, which would otherwise show as a dot before drawing starts.
A near-zero fade never commits its end value, so it is a short, real one: the pen touching down */}
<motion.g
initial={{ opacity: reduceMotion ? 1 : 0 }}
animate={{ opacity: drawn ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.15, delay: reduceMotion ? 0 : delay }}
>
<motion.path
d={markPath(variant, size.w, size.h)}
fill="none"
stroke={color}
strokeWidth={thickness}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: drawn ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : duration, delay: reduceMotion ? 0 : delay, ease: [0.65, 0, 0.35, 1] }}
/>
</motion.g>
</svg>
)}
</span>
</span>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The words to mark. Keep it to a few words on one line. |
| variant | "scribble" | "swoosh" | "double" | "circle" | "strike" | "scribble" | The mark drawn. |
| color | string | "#5FAEB4" | Line colour. |
| thickness | number | 3 | Line width in pixels. |
| duration | number | 0.7 | Seconds to draw. |
| delay | number | 0 | Seconds to wait once in view. |
We build systems that sell for brands that are growing.
Highlighter
A marker-pen sweep draws behind your key words as they scroll into view, line by line like a real pen.
Monthly revenue
Number Ticker
Counts up to a value with an eased finish when it scrolls into view.
Small details make the whole thing feel alive.
Word Rise
Words lift out of a blur one after another as the line scrolls into view.