Line Draw on Scroll
SVG paths that draw themselves as the block scrolls through the viewport, dashoffset unwinding stroke by stroke.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/line-draw-scroll.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LineDrawScroll" component (no animation library).
Contract
- Export a forwardRef<HTMLDivElement> component extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children">, spreading the rest onto the root div.
- Props: paths (array of `d` strings, or objects { d, color?, weight?, strokeWidth?, fill? }),
children (your own <svg>; when present it replaces the generated one), viewBox (default
"0 0 100 100", only used by the paths form), sequence ("sequential" | "parallel", default
"sequential"), start (0..1, default 0.85) and end (0..1, default 0.35) — the fractions down the
scrollport where drawing begins and completes, rewind (boolean, default false), color (token name:
foreground / muted-foreground / primary / chart-1..chart-5, default "primary"), strokeWidth
(number, user units, default 2), lineCap ("butt" | "round" | "square", default "round"),
fillOnComplete (boolean, default false), pathSelector (CSS selector, default every geometry
element: "path, line, polyline, polygon, circle, ellipse, rect"), label (string — the accessible
name), refreshKey (React.Key, bump it after swapping the artwork inside children).
Behavior
- One effect owns everything. It queries `pathSelector` inside the root, keeps the elements that
answer getTotalLength(), measures each one (wrapped in try/catch — a shape the browser refuses to
measure keeps its own paint and is simply left drawn), and writes
strokeDasharray = `${length} ${length}` once. Per frame it only moves strokeDashoffset from
`length` (invisible) to 0 (fully drawn), rounded to 3 decimals and skipped when the value has not
changed.
- Windows: sequential gives each path a consecutive slice of the 0..1 window sized by
weight / totalWeight, so one finishes before the next starts; parallel gives every path from = 0
and span = weight / heaviestWeight, so they start together and the heaviest lands last. With the
default weight of 1 everywhere, parallel is exact lockstep. In the children form the same number
is read from a `data-draw-weight` attribute on the element, so there is one weight source, not two.
- Scroll mapping, per animation frame: resolve the scrollport once (nearest ancestor whose computed
overflow-y is auto/scroll, else the viewport — hidden and clip must NOT count: they clip without
ever moving their content, and adopting a rounded overflow-hidden card as the scrollport would
freeze the mapping). startLine = portHeight * start, endLine = portHeight * end,
travel = max(1, startLine - endLine + blockHeight), moved = startLine - (blockTop - portTop),
progress = clamp01(moved / travel).
- Short-page backstop: reach = moved + scroll room left in the port is invariant under scrolling
(scrolling by d lowers the block by d and eats d of the room), so it is a property of the layout.
Clamp the denominator to min(travel, reach) and the artwork always finishes by the time the port
bottoms out; when reach <= 0 nothing can ever push the block past the start line, so render it
finished rather than blank.
- rewind=false keeps a running peak so progress never goes backwards; rewind=true binds the offset
to the scroll position in both directions (a scrubbable drawing).
- Listeners: one scroll listener on window with { capture: true, passive: true } (capture catches
nested scrollers), a resize listener that re-resolves the port, a ResizeObserver on the root
(content loading above it moves it with no scroll event), and an IntersectionObserver that gates
scheduling — plus one authoritative frame on every boundary crossing in both directions, so a fast
flick that leaves the viewport between two frames cannot freeze the drawing half done. Every scroll
tick funnels through one requestAnimationFrame, never a per-event computation. The cleanup cancels
the frame, removes both listeners, disconnects both observers, and restores the four inline styles
it overwrote (dasharray, dashoffset, fill-opacity, transition) — it runs before every dependency
rebuild too, so switching mode mid-scroll leaves a complete drawing, not a frozen half-drawn one.
- fillOnComplete sets a `fill-opacity 420ms ease-out` transition once and flips fill-opacity 0 -> 1
the moment a shape's own outline is complete.
- Reduced motion and SSR are the same guarantee, not two special cases: the dash is applied only by
the running effect, so the server, a client with no JS, and anyone with prefers-reduced-motion all
get the finished artwork, because nothing ever hid it. Read the media query with
useSyncExternalStore over matchMedia (server snapshot false, listener removed on unmount) so
flipping the OS setting mid-session re-runs the effect in both directions. Use a layout effect
(useEffect on the server) so the first dashed frame is the first painted frame — no flash of the
complete drawing before it disappears to be drawn.
Rendering & styling
- Semantic tokens only. The root div carries stroke / stroke-width / stroke-linecap /
stroke-linejoin as inherited CSS, so one declaration paints both the generated paths and any <svg>
the consumer passes in — while their own stroke attributes still win, because an attribute on the
element beats a value inherited from an ancestor. Colours resolve from a token map to
var(--primary) / var(--foreground) / var(--muted-foreground) / var(--chart-1..5). No hex, no
palette classes.
- Generated paths render with fill="none" (or the fill token when fillOnComplete) and no dash
attributes, so the server-rendered markup is the complete drawing. The generated <svg> takes its
width from the root and its height from the viewBox ratio, with overflow-visible so a thick stroke
sitting on the edge of the viewBox is not clipped — the consumer sizes the block with className.
- Accessibility: without `label` the root is aria-hidden="true" — a drawn line is decoration until
you say otherwise. With it the root becomes role="img" + aria-label, which makes the graphic a
leaf in the accessibility tree. Merge className with cn() and pass every extra prop through.
- Caveat worth documenting: a `pathLength` attribute on a consumer path rescales SVG's own distance
maths, so the measured dash no longer matches — such a path simply renders complete.
Customization levers
- Choreography: sequence "sequential" for a pen that draws one thing at a time (illustrations,
signatures), "parallel" for a group that lands together (connectors, sibling strokes).
- Pace: weight per path is a relative share — give a long trail 3 and a short horizon 1 so the pen
keeps something like a constant speed, or weight a hero stroke heavily so it is the last to land.
- Window: start/end are viewport fractions. start 0.9 / end 0.4 draws it in the middle of the
scroll; start 0.6 / end 0.5 makes the whole drawing happen inside a short, sharp band.
- Behaviour: rewind on for a scrubbable diagram the reader can play back and forth; off (default)
for prose, where what has been read stays drawn.
- Ink: color plus per-path color to give each stroke its own chart token; strokeWidth 1–2 reads
technical, 4–6 reads like a felt pen; lineCap "butt" for maps and diagrams, "round" for hand-drawn.
- Fill: fillOnComplete for closed contours (logos, leaf/blob shapes) — leave it off for open line
work, where a fill would smear the drawing.
- Scope: pathSelector "path[data-draw]" to draw only the strokes you tagged and leave background
shapes fully painted from the start.Concepts
- Dashoffset as a pen — one dash exactly as long as the path plus a gap the same size means
stroke-dashoffset = lengthhides the stroke entirely and0shows all of it; every value in between is a pen tip. Nothing is masked, clipped or re-rendered, so the per-frame cost is one string write per path. - Measure in an effect, never in render —
getTotalLength()needs a layout, which the server does not have; measuring it in a layout effect keeps render pure and means the first painted frame is already dashed, with no flash of the finished drawing. - Absence as the fallback — the dash only exists while the effect is running, so reduced motion, SSR, a failed hydration and an unmeasurable shape all land on the same safe state: the complete artwork. There is no code path that can leave a reader looking at nothing.
- A window, not a trigger — progress is the block's position between two viewport lines (
starton its top edge,endon its bottom), so the reader scrubs the drawing rather than tripping it. The lines are fractions, so the same component behaves the same on a laptop and a phone. - Layout-invariant short-page backstop — the remaining scroll plus the distance already moved is unchanged by scrolling, which makes it a property of the layout: compressing the window onto that number guarantees the drawing finishes before the scrollport bottoms out, instead of stranding a half-drawn illustration on a short page.
- Choreography by weight —
sequentialqueues paths on consecutive slices of the window andparalleloverlaps them from a shared start; in both, a per-pathweightdecides how much scroll a stroke is worth, which is what stops a long trail from being drawn as fast as a horizon line. - Draw-and-hold vs rewind — a monotonic peak makes the drawing behave like reading (what is drawn stays drawn); binding it to raw scroll makes it a scrubbable diagram. Same mapping, one boolean apart.
- Decoration until declared otherwise — the root is
aria-hiddenby default; passinglabelpromotes it torole="img"with an accessible name, so an informative diagram is announced once and a flourish is silent.
Wave Divider
A live seam between two sections — stacked wave layers re-sampled every frame from two counter-travelling harmonics, with an opaque front wave painted in the next section's own token.
Spinner
One indeterminate loader, seven shapes — three boxed (ring, dual-ring, bars) and four inline dot rows (dots, ellipsis, bounce, wave), colored by a single tone prop or plain currentColor.