SVG strokes that write themselves in, one after another. A signature, an underline or a checkmark.
pnpm dlx shadcn@latest add @latheui/line-drawFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { useInView } from "motion/react"
import { cn } from "@/lib/utils"
import { useMotionSafe } from "@/lib/motion"
export type LineDrawProps = Omit<React.ComponentProps<"svg">, "children"> & {
/** SVG path data, drawn one after another in this order. */
paths: string[]
viewBox: string
/** Accessible name for the drawing. Without one it is treated as decoration. */
label?: string
/** Stroke color. Any CSS color. */
color?: string
strokeWidth?: number
/** Seconds each path takes to draw. */
duration?: number
/** Seconds between the start of one path and the next. */
stagger?: number
/** A faint copy of the paths underneath, like tracing paper. */
guide?: boolean
/** Draw when it mounts, or wait until it scrolls into view. */
trigger?: "mount" | "inView"
}
/** SVG strokes that draw themselves in, one after another. To draw again, change its key. */
function LineDraw({
paths,
viewBox,
label,
color = "currentColor",
strokeWidth = 3,
duration = 1.5,
stagger = 0.22,
guide = false,
trigger = "inView",
className,
...props
}: LineDrawProps) {
const { reduced } = useMotionSafe()
const ref = React.useRef<SVGSVGElement>(null)
const inView = useInView(ref, { once: true, amount: 0.4 })
const ready = trigger === "mount" || inView
React.useEffect(() => {
const lines = ref.current?.querySelectorAll<SVGPathElement>("[data-draw]")
if (!lines || !ready) return
if (reduced) {
lines.forEach((line) => (line.style.strokeDashoffset = "0"))
return
}
const strokes = Array.from(lines, (line, index) =>
line.animate([{ strokeDashoffset: 1.01 }, { strokeDashoffset: 0 }], {
duration: duration * 1000,
delay: index * stagger * 1000,
easing: "cubic-bezier(.65,0,.35,1)",
fill: "both",
})
)
return () => strokes.forEach((stroke) => stroke.cancel())
}, [ready, reduced, duration, stagger])
return (
<svg
ref={ref}
viewBox={viewBox}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
data-slot="line-draw"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("overflow-visible", className)}
{...props}
>
{guide &&
paths.map((d, index) => (
<path key={`guide-${index}`} d={d} stroke="var(--border)" strokeWidth={1} />
))}
{paths.map((d, index) => (
<path
key={index}
data-draw=""
d={d}
// Every path counts as 1 long, so the same dash hides or shows it whatever its real length.
pathLength={1}
stroke={color}
strokeWidth={strokeWidth}
// One dash the length of the path and a longer gap, so only that dash ever falls on it.
// Offset a hair past 1, so not even a round cap shows before the stroke starts.
strokeDasharray="1 2"
strokeDashoffset={1.01}
/>
))}
</svg>
)
}
export { LineDraw }Depends on
Also accepts native svg attributes. Size it with className.
| Prop | Type | Default | Description |
|---|---|---|---|
| paths* (required) | string[] | — | SVG path data, drawn one after another in this order. |
| viewBox* (required) | string | — | The coordinate space of the paths. |
| label | string | — | Accessible name. Without one the drawing is hidden from assistive tech. |
| color | string | "currentColor" | Stroke color. |
| strokeWidth | number | 3 | Stroke width in viewBox units. |
| duration | number | 1.5 | Seconds each path takes to draw. |
| stagger | number | 0.22 | Seconds between the start of one path and the next. |
| guide | boolean | false | A faint copy of the paths underneath. |
| trigger | "mount" | "inView" | "inView" | Draw as soon as it mounts, or once it scrolls into view. |
Change the key to start from nothing.
const [run, setRun] = React.useState(0)
<LineDraw key={run} paths={paths} viewBox="0 0 300 160" />
<Button onClick={() => setRun((n) => n + 1)}>Replay</Button>