Display

Line Draw on Scroll

SVG paths that draw themselves as the block scrolls through the viewport, dashoffset unwinding stroke by stroke.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Consumers name a token, never a colour — restyle the theme and the ink follows, dark mode free. */
const TOKENS = {
  foreground: "var(--foreground)",
  "muted-foreground": "var(--muted-foreground)",
  primary: "var(--primary)",
  "chart-1": "var(--chart-1)",
  "chart-2": "var(--chart-2)",
  "chart-3": "var(--chart-3)",
  "chart-4": "var(--chart-4)",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/line-draw-scroll.json

Prompt

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 = length hides the stroke entirely and 0 shows 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 rendergetTotalLength() 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 (start on its top edge, end on 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 weightsequential queues paths on consecutive slices of the window and parallel overlaps them from a shared start; in both, a per-path weight decides 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-hidden by default; passing label promotes it to role="img" with an accessible name, so an informative diagram is announced once and a flourish is silent.

On This Page