Svg Draw On Scroll
An SVG path that draws itself in step with scroll. The stroke is revealed by
animating stroke-dashoffset on the progress value returned by useScroll, so
the artwork appears to be sketched in as the element moves through the viewport.
Give it a path, or a paths array to draw several in sequence.
There is also a marker slot: content that rides the tip of the line as it draws,
positioned from the path's own geometry rather than CSS offset-path, so it stays
locked to the stroke at any size or viewBox.
Usage
Give the wrapper an explicit size, pass a paths array, and point container at a
scrollable ancestor so the draw is driven by that panel rather than the page.
container must be passed as a getter (:container="() => panel") — see
Notes for why. The marker slot rides the tip of the stroke.
Scroll the panel — the four paths are drawn in sequence as it moves.
<template>
<div class="flex w-full max-w-md flex-col gap-3">
<p class="text-sm text-muted">
Scroll the panel — the four paths are drawn in sequence as it moves.
</p>
<div
ref="panel"
class="relative h-64 overflow-y-auto overscroll-contain rounded-2xl border border-default bg-default"
>
<div class="h-48" />
<MSvgDrawOnScroll
:container="() => panel"
:offset="['start 0.75', 'end 0.25']"
:paths="paths"
viewBox="0 0 100 100"
:stroke-width="1.4"
stroke="var(--ui-primary)"
class="mx-auto h-40 w-40"
>
<template #marker>
<span class="block size-3 rounded-full bg-primary ring-4 ring-primary/20" />
</template>
</MSvgDrawOnScroll>
<div class="h-48" />
</div>
</div>
</template>
<script setup lang="ts">
const panel = ref<HTMLElement | null>(null)
const paths = [
'M12 78 C 26 40, 40 62, 50 38 C 60 14, 74 44, 88 22',
'M14 22 L 50 50 L 86 22',
'M50 8 L 50 92',
'M10 50 L 90 50',
]
</script>
Examples
A single path
For one shape, path takes a bare d string. A viewBox that matches the path's
coordinate space keeps it from being stretched by preserveAspectRatio.
<template>
<div class="relative h-64 overflow-y-auto overscroll-contain rounded-2xl border border-default bg-default">
<div class="h-40" />
<MSvgDrawOnScroll
path="M10 80 C 30 20, 70 20, 90 80 S 110 20, 130 80"
view-box="0 0 140 100"
:stroke-width="2"
class="mx-auto h-36 w-56 text-primary"
/>
<div class="h-40" />
</div>
</template>
Draw once
With once, the path latches fully drawn at its furthest point and is not erased
when you scroll back up — useful when the draw is a one-time entrance.
<template>
<div class="relative h-64 overflow-y-auto rounded-2xl border border-default">
<div class="h-40" />
<MSvgDrawOnScroll
once
:paths="['M10 50 C 30 10, 70 90, 90 50']"
view-box="0 0 100 100"
stroke="var(--ui-primary)"
class="mx-auto h-32 w-40"
/>
<div class="h-40" />
</div>
</template>
Customize
Adjust the path, viewBox, stroke, strokeWidth and once live in the
playground below.
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
path | string | - | A single path's d. Ignored when paths is also provided. |
paths | string[] | - | Multiple paths, drawn one after another across the scroll range. Each path gets an equal slice of progress, so four paths mean each one draws over a quarter of the range. |
viewBox | string | '0 0 100 100' | The SVG viewBox. Match it to your path coordinates; the SVG scales to the
wrapper with preserveAspectRatio="xMidYMid meet". |
offset | ScrollOffset | ['start 0.9', 'end 0.6'] | Motion useScroll offset tuple for the draw. The default finishes the draw while
the artwork is still mid-viewport rather than on its way out. |
container | MaybeComputedElementRef | - | A scrollable ancestor that drives the draw instead of the window. Must be passed
as a getter (:container="() => panel"). Without it, several instances on one
page all read the same page-level progress and their ranges overlap, so only the
last one appears to work. The container must be position: relative. |
once | boolean | false | Freeze fully drawn once complete instead of undrawing on scroll-up. Implemented by tracking the high-water mark of progress. |
stroke | string | 'currentColor' | Stroke colour. Anything other than currentColor is resolved through the theme
colour helper, so var(--ui-primary) and theme tokens work. |
strokeWidth | number | 2 | Stroke width in viewBox units. |
class | string | - | Merged onto the root wrapper after relative. Give it an explicit size — the
inner SVG is h-full w-full, so the wrapper drives the drawing area. |
Slots
| Slot | Props | Description |
|---|---|---|
marker | - | Content that rides the tip of the stroke as it draws. It is positioned from the path's geometry, so only the marker's own appearance is up to you. When no marker slot is provided, no marker element is rendered at all. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.svgDrawOnScroll in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative |
svg | - | h-full w-full |
marker | - | pointer-events-none absolute top-0 left-0 |
markerInner | - | -translate-x-1/2 -translate-y-1/2 |
Accessibility
The <svg> is marked aria-hidden="true" and the marker wrapper is also
aria-hidden, so neither the artwork nor the marker is exposed to assistive
technology. This component is decorative by design — if the graphic carries
meaning, describe it in surrounding text.
There are no roles, live regions, focus handling or keyboard interaction. Under
prefers-reduced-motion the paths are drawn in full immediately (pathLength of
1) and the marker is pinned to the end of the path, so the artwork is presented
complete rather than animated.
Performance
- One
useScrollon the wrapper (or your container) and oneuseSpring(damping: 28,mass: 0.4,stiffness: 240) for the whole component. - The spring value is read once into a plain ref through a single
useMotionValueEvent, and each path's dash offset is then a pure function of that one number — rather than auseTransformper path, which would re-subscribe on every recomputation and leak. pathLength="1"on each path normalises the dash units, so astrokeDashoffsetof1hides the stroke and0reveals it. The browser paints it as a normal dash animation without re-measuring path lengths.- The marker reads geometry on change and on
ResizeObserverupdates, not per frame:getTotalLengthandgetScreenCTMare called only when the spring value or the wrapper size changes.
Notes
container must be a getter
Pass :container="() => panel", never :container="panel". A plain binding is
unwrapped by the template and captured as null during setup, after which
nothing re-reads it and useScroll quietly falls back to the window. The getter
is invoked inside Motion's own flush: 'post' effect, where your ref is a tracked
dependency, so it resolves to the mounted element and re-resolves if it changes.
Why the marker is measured, not positioned with offset-path
offset-path: path() measures in the element's CSS pixel space, but the path's d
is written in viewBox units. Any viewBox that is not 1:1 with the rendered box —
the normal case, since the SVG scales with preserveAspectRatio — would leave the
marker floating away from the stroke. Instead the component resolves the tip
through getPointAtLength and getScreenCTM, the same transform the browser used
to paint the line, so the marker stays attached at any scale.
paths draw in sequence.