Text Animationsv0.1.0
Annotated Text
Hand-drawn underlines, circles and highlights.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/annotated-text.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --warning, which shadcn does not define by default. Add it to your global CSS:
app/globals.css:root { --warning: #b45309; } .dark { --warning: #fbbf24; } @theme inline { --color-warning: var(--warning); }Copy the source
components/ui/annotated-text/annotated-text.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { ease } from "@/lib/motion"; export type AnnotationType = "underline" | "highlight" | "circle" | "box" | "strike" | "bracket"; export interface AnnotatedTextProps extends Omit<ComponentProps<"span">, "children"> { /** The text to annotate. Can wrap across lines (underline, highlight and strike follow each line). */ children: ReactNode; /** Annotation style. @default "underline" */ type?: AnnotationType; /** Stroke or marker color. @default "var(--primary)", or a soft amber for highlight */ color?: string; /** Stroke width in px (not used by highlight). @default 2 */ strokeWidth?: number; /** Seconds to draw. @default 0.8 */ duration?: number; /** Seconds before drawing starts; chain several annotations by staggering this. @default 0 */ delay?: number; /** Draw on mount, when scrolled into view, or whenever `show` is true. @default "inView" */ trigger?: "inView" | "mount" | "manual"; /** Shows or hides the annotation when trigger is "manual". */ show?: boolean; /** Space between the text and the mark, in px. @default 4 */ padding?: number; /** Draw the stroke twice, the second pass slightly off, like a real pen. @default 1 */ iterations?: 1 | 2; } type Line = { x: number; y: number; w: number; h: number }; type Mark = { d: string; fill?: boolean; delay: number; duration: number }; /** Small deterministic PRNG, so a mark keeps the same wobble across re-measures. */ function random(seed: number) { let s = seed; return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function hash(value: string) { let h = 2166136261; for (let i = 0; i < value.length; i++) h = Math.imul(h ^ value.charCodeAt(i), 16777619); return h >>> 0; } const f = (n: number) => Math.round(n * 10) / 10; function marks(type: AnnotationType, lines: Line[], pad: number, iterations: number, total: number, seed: number): Mark[] { const rand = random(seed); const j = (amount: number) => (rand() - 0.5) * 2 * amount; const out: Mark[] = []; const passes = type === "highlight" || type === "bracket" ? 1 : iterations; if (type === "underline" || type === "strike" || type === "highlight") { // One stroke per line, drawn in sequence, each taking its share of the time by width. const widths = lines.reduce((sum, l) => sum + l.w, 0) || 1; for (let pass = 0; pass < passes; pass++) { let start = pass * total * 0.55; for (const l of lines) { const share = (total * l.w) / widths; const x0 = l.x - pad * 0.5 + j(1); const x1 = l.x + l.w + pad * 0.5 + j(1); if (type === "highlight") { const top = l.y + l.h * 0.12; const bottom = l.y + l.h * 0.94; const mid = (x0 + x1) / 2; out.push({ d: `M${f(x0)} ${f(top + j(1.5))} Q${f(mid)} ${f(top + j(2))} ${f(x1)} ${f(top + j(1.5))} L${f(x1 + j(2))} ${f(bottom + j(1.5))} Q${f(mid)} ${f(bottom + j(2))} ${f(x0 + j(2))} ${f(bottom + j(1.5))}Z`, fill: true, delay: start, duration: share, }); } else { const y = type === "underline" ? l.y + l.h - l.h * 0.04 : l.y + l.h * 0.56; const w = x1 - x0; out.push({ d: `M${f(x0)} ${f(y + j(1.5))} C${f(x0 + w * 0.3)} ${f(y + j(2.5))} ${f(x0 + w * 0.7)} ${f(y + j(2.5))} ${f(x1)} ${f(y - 1 + j(1.5))}`, delay: start, duration: share, }); } start += share; } } return out; } // Shapes around the whole text use its bounding box. const left = Math.min(...lines.map((l) => l.x)); const top = Math.min(...lines.map((l) => l.y)); const right = Math.max(...lines.map((l) => l.x + l.w)); const bottom = Math.max(...lines.map((l) => l.y + l.h)); for (let pass = 0; pass < passes; pass++) { const delay = pass * total * 0.6; if (type === "circle") { const cx = (left + right) / 2; const cy = (top + bottom) / 2; const rx = (right - left) / 2 + pad + 3; const ry = (bottom - top) / 2 + pad + 1; const from = -Math.PI * 0.62 + j(0.15); const sweep = Math.PI * 2 + 0.35; const wobble = rand() * Math.PI * 2; const points: [number, number][] = []; for (let k = 0; k <= 40; k++) { const a = from + (sweep * k) / 40; const r = 1 + 0.035 * Math.sin(a * 3 + wobble) + (k / 40) * 0.05; points.push([cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r]); } let d = `M${f(points[0][0])} ${f(points[0][1])}`; for (let k = 1; k < points.length - 1; k++) { const [x, y] = points[k]; const [nx, ny] = points[k + 1]; d += ` Q${f(x)} ${f(y)} ${f((x + nx) / 2)} ${f((y + ny) / 2)}`; } out.push({ d, delay, duration: total }); } else if (type === "box") { const x0 = left - pad - 2; const y0 = top - pad + 1; const x1 = right + pad + 2; const y1 = bottom + pad - 1; out.push({ d: `M${f(x0 - 3 + j(1))} ${f(y0 + j(1))} L${f(x1 + j(1.5))} ${f(y0 + j(1.5))} L${f(x1 + j(1.5))} ${f(y1 + j(1.5))} L${f(x0 + j(1.5))} ${f(y1 + j(1.5))} L${f(x0 + j(1))} ${f(y0 - 3)}`, delay, duration: total, }); } else { const x0 = left - pad; const x1 = right + pad; const y0 = top - 2; const y1 = bottom + 2; const arm = Math.min(10, (right - left) / 4); out.push({ d: `M${f(x0 + arm)} ${f(y0 + j(1))} L${f(x0 + j(1))} ${f(y0)} L${f(x0 + j(1))} ${f(y1)} L${f(x0 + arm)} ${f(y1 + j(1))}`, delay, duration: total }); out.push({ d: `M${f(x1 - arm)} ${f(y0 + j(1))} L${f(x1 + j(1))} ${f(y0)} L${f(x1 + j(1))} ${f(y1)} L${f(x1 - arm)} ${f(y1 + j(1))}`, delay, duration: total }); } } return out; } export function AnnotatedText({ children, type = "underline", color, strokeWidth = 2, duration = 0.8, delay = 0, trigger = "inView", show, padding = 4, iterations = 1, className, ...props }: AnnotatedTextProps) { const reduceMotion = useReducedMotion(); const ref = useRef<HTMLSpanElement>(null); const seed = hash(useId() + type); const [lines, setLines] = useState<Line[] | null>(null); const inView = useInView(ref, { once: true, amount: 0.8 }); const visible = trigger === "manual" ? !!show : trigger === "mount" || inView; // Positions of each line box, relative to the first one (where absolute children of an inline element start). useEffect(() => { const el = ref.current; if (!el) return; let frame = 0; const measure = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const rects = Array.from(el.getClientRects()).filter((r) => r.width > 0); if (!rects.length) return; const origin = rects[0]; setLines(rects.map((r) => ({ x: r.left - origin.left, y: r.top - origin.top, w: r.width, h: r.height }))); }); }; measure(); document.fonts?.ready.then(measure); // Inline boxes can't be observed directly; the parent block resizing is what rewraps the text. const observer = new ResizeObserver(measure); if (el.parentElement) observer.observe(el.parentElement); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, []); const stroke = color ?? (type === "highlight" ? "color-mix(in oklab, var(--warning) 32%, transparent)" : "var(--primary)"); const list = lines ? marks(type, lines, padding, iterations, duration, seed) : []; return ( <span ref={ref} className={cn("relative", type === "highlight" && "z-0", className)} {...props}> {children} {list.length ? ( <svg aria-hidden="true" width="1" height="1" className={cn("pointer-events-none absolute top-0 left-0 overflow-visible", type === "highlight" && "-z-10")} > {list.map((mark, i) => mark.fill ? ( // The marker sweeps open with scaleX: a transform, not a redraw. <motion.path key={i} d={mark.d} fill={stroke} style={{ originX: 0, transformBox: "fill-box" }} initial={reduceMotion ? { opacity: 0 } : { scaleX: 0, opacity: 1 }} animate={visible ? { scaleX: 1, opacity: 1 } : reduceMotion ? { opacity: 0 } : { scaleX: 0 }} transition={ reduceMotion ? { duration: 0.3, delay: visible ? delay : 0 } : { duration: visible ? mark.duration : 0.25, delay: visible ? delay + mark.delay : 0, ease: ease.out } } /> ) : ( <motion.path key={i} d={mark.d} fill="none" stroke={stroke} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" initial={reduceMotion ? { opacity: 0, pathLength: 1 } : { pathLength: 0, opacity: 0 }} animate={visible ? { pathLength: 1, opacity: 1 } : reduceMotion ? { opacity: 0 } : { pathLength: 0, opacity: 0 }} transition={ reduceMotion ? { duration: 0.3, delay: visible ? delay : 0 } : { pathLength: { duration: visible ? mark.duration : 0.25, delay: visible ? delay + mark.delay : 0, ease: ease.inOut }, opacity: { duration: 0.01, delay: visible ? delay + mark.delay : 0.25 }, } } /> ), )} </svg> ) : null} </span> ); }
Usage
import { AnnotatedText } from "@/components/ui/annotated-text/annotated-text";
export function Pitch() {
return (
<p>
Ship <AnnotatedText>components</AnnotatedText> that feel{" "}
<AnnotatedText type="highlight" delay={0.6}>handcrafted</AnnotatedText>.
</p>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | – | The text to annotate. Can wrap across lines (underline, highlight and strike follow each line). |
type | AnnotationType | "underline" | Annotation style. |
color | string | "var(--primary)", or a soft amber for highlight | Stroke or marker color. |
strokeWidth | number | 2 | Stroke width in px (not used by highlight). |
duration | number | 0.8 | Seconds to draw. |
delay | number | 0 | Seconds before drawing starts; chain several annotations by staggering this. |
trigger | "inView" | "mount" | "manual" | "inView" | Draw on mount, when scrolled into view, or whenever `show` is true. |
show | boolean | – | Shows or hides the annotation when trigger is "manual". |
padding | number | 4 | Space between the text and the mark, in px. |
iterations | 1 | 2 | 1 | Draw the stroke twice, the second pass slightly off, like a real pen. |
Also accepts every native <span> attribute, including ref and className.
- Underline, highlight and strike follow each line when the text wraps; circle, box and bracket surround the whole text. Shapes are re-measured when fonts load or the container resizes.
- Strokes draw with pathLength (stroke-dashoffset on a small SVG); the highlight marker sweeps open with scaleX.
- The wobble is seeded per instance, so a mark keeps its shape across re-renders. Chain several annotations by staggering `delay`.
Accessibility
- Marks are a decorative, aria-hidden SVG; the text inside is untouched. If the emphasis carries meaning, wrap the text in <strong>, <mark> or <s> as well.
- With reduced motion enabled, marks fade in instead of being drawn.