Scroll drawing
A line drawing that draws itself while you scroll and undraws when you scroll back. Four drawings included, any SVG paths work.
One of 4 components in Micro animations
Variants
Details
What Scroll drawing brings along, read from the registry and the source.
- Category
- Micro animations
- Area
- Visuals
- Variants
- 4
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/scroll-drawing.tsx
- Brings along
- utilskitmotion
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ScrollDrawingProps interface from components/ui/scroll-drawing.tsx, with the comments from the code.
drawingoptionalDrawingNamehouseroutegrowthworkspace
A built-in drawing, or pass your own strokes
strokesoptionalDrawingStroke[]viewBoxoptionalstringviewBox of your own strokes. Default "0 0 800 500".
startoptionalnumberScroll progress where drawing starts and where it is complete (0 = element enters, 1 = element leaves). Default 0.1 and 0.55.
endoptionalnumberlabeloptionalstringAccessible description
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 205 lines
'use client';
import { cn, round } from '@/lib/kit';
import { useScrollProgress } from '@/components/ui/motion';
/*
* A line drawing that draws itself while you scroll. Every stroke has a
* window of the scroll progress in which it goes from nothing to complete
* (stroke-dashoffset), labels fade in once their stroke is done. Scrolling
* back undraws it. Without JavaScript and with reduced motion the drawing is
* complete. Four drawings come with it, any SVG paths work.
*/
export interface DrawingStroke {
d: string;
/** line (default), accent or soft */
tone?: 'line' | 'accent' | 'soft';
width?: number;
/** Order of drawing. Strokes with the same step draw together. Default: their position. */
step?: number;
/** Dashed, for guides and dimensions */
dashed?: boolean;
}
export interface DrawingLabel {
x: number;
y: number;
text: string;
/** Step after which the label appears */
step: number;
anchor?: 'start' | 'middle' | 'end';
tone?: 'line' | 'accent';
}
export type DrawingName = 'house' | 'route' | 'growth' | 'workspace';
export interface ScrollDrawingProps {
/** A built-in drawing, or pass your own strokes */
drawing?: DrawingName;
strokes?: DrawingStroke[];
/** Labels for your own strokes, or texts for the built-in labels in order */
labels?: DrawingLabel[] | string[];
/** viewBox of your own strokes. Default "0 0 800 500". */
viewBox?: string;
/** Scroll progress where drawing starts and where it is complete (0 = element enters, 1 = element leaves). Default 0.1 and 0.55. */
start?: number;
end?: number;
/** Accessible description */
label?: string;
className?: string;
}
const L = (d: string, tone: DrawingStroke['tone'] = 'line', step?: number, width?: number, dashed?: boolean): DrawingStroke => ({ d, tone, step, width, dashed });
export const DRAWINGS: Record<DrawingName, { strokes: DrawingStroke[]; labels: Omit<DrawingLabel, 'text'>[]; texts: string[] }> = {
house: {
strokes: [
L('M60 430H740', 'soft', 0),
L('M210 430V250L400 130L590 250V430', 'line', 1, 2.2),
L('M180 268L400 116L620 268', 'line', 2, 2.2),
L('M500 186V136H540V214', 'line', 3),
L('M360 430V330Q360 318 372 318H428Q440 318 440 330V430', 'accent', 4, 2.2),
L('M245 290H320V360H245ZM282.5 290V360M245 325H320', 'line', 5),
L('M480 290H555V360H480ZM517.5 290V360M480 325H555', 'line', 5),
L('M375 205a25 25 0 1 0 50 0a25 25 0 1 0-50 0M400 180V230M375 205H425', 'line', 6),
L('M660 430V370M660 380Q620 350 640 310Q630 270 668 262Q700 250 705 292Q730 318 700 350Q690 372 660 370', 'soft', 7),
L('M210 462H590M210 452V472M590 452V472', 'accent', 8, 1.4, true),
L('M150 430V130M140 130H160M140 430H160', 'accent', 8, 1.4, true),
],
labels: [
{ x: 400, y: 490, step: 8, anchor: 'middle', tone: 'accent' },
{ x: 136, y: 284, step: 8, anchor: 'end', tone: 'accent' },
{ x: 400, y: 404, step: 4, anchor: 'middle' },
],
texts: ['12.40 m', '7.80 m', 'Entrance'],
},
route: {
strokes: [
L('M40 120C160 90 240 160 360 130S560 60 760 110', 'soft', 0),
L('M40 210C150 190 260 250 380 220S580 160 760 200', 'soft', 0),
L('M40 330C170 300 280 370 420 340S600 290 760 320', 'soft', 0),
L('M40 430C190 410 300 460 450 440S640 400 760 430', 'soft', 0),
L('M520 30C500 120 560 180 530 260S470 380 520 490', 'soft', 1, 3),
L('M110 400C170 380 180 300 250 290S330 340 390 300S420 200 480 190S560 230 610 170S650 110 700 100', 'accent', 2, 3.2),
L('M110 400m-14 0a14 14 0 1 0 28 0a14 14 0 1 0-28 0', 'accent', 1, 2.4),
L('M700 100c0-22-16-34-30-34s-30 12-30 34c0 20 30 50 30 50s30-30 30-50ZM670 92m-8 0a8 8 0 1 0 16 0a8 8 0 1 0-16 0', 'line', 3, 2.2),
L('M250 290m-6 0a6 6 0 1 0 12 0a6 6 0 1 0-12 0M480 190m-6 0a6 6 0 1 0 12 0a6 6 0 1 0-12 0', 'line', 3, 2),
],
labels: [
{ x: 110, y: 444, step: 1, anchor: 'middle' },
{ x: 262, y: 270, step: 3, anchor: 'start' },
{ x: 492, y: 172, step: 3, anchor: 'start' },
{ x: 700, y: 176, step: 3, anchor: 'middle', tone: 'accent' },
],
texts: ['Start', 'Km 4', 'Km 9', 'Summit 1,840 m'],
},
growth: {
strokes: [
L('M90 60V420H740', 'line', 0, 2),
L('M90 330H740M90 240H740M90 150H740', 'soft', 0, 1, true),
L('M90 400C170 390 200 360 260 350S350 330 400 290S480 250 540 210S640 120 720 90', 'accent', 1, 3.2),
L('M90 400C170 390 200 360 260 350S350 330 400 290S480 250 540 210S640 120 720 90V420H90Z', 'soft', 2, 1),
L('M260 350m-7 0a7 7 0 1 0 14 0a7 7 0 1 0-14 0', 'accent', 3, 2.4),
L('M400 290m-7 0a7 7 0 1 0 14 0a7 7 0 1 0-14 0', 'accent', 4, 2.4),
L('M720 90m-9 0a9 9 0 1 0 18 0a9 9 0 1 0-18 0', 'accent', 5, 2.6),
L('M260 340V300M400 280V240M720 80V44', 'line', 5, 1.4, true),
],
labels: [
{ x: 260, y: 290, step: 3, anchor: 'middle' },
{ x: 400, y: 230, step: 4, anchor: 'middle' },
{ x: 720, y: 34, step: 5, anchor: 'end', tone: 'accent' },
{ x: 90, y: 446, step: 0, anchor: 'start' },
{ x: 740, y: 446, step: 0, anchor: 'end' },
],
texts: ['New website', 'Online booking', '+212% bookings', 'January', 'December'],
},
workspace: {
strokes: [
L('M60 400H740M100 400V480M700 400V480', 'line', 0, 2.2),
L('M250 160Q250 150 260 150H540Q550 150 550 160V320Q550 330 540 330H260Q250 330 250 320Z', 'line', 1, 2.2),
L('M388 330V372M412 330V372M352 400Q352 372 372 372H428Q448 372 448 400', 'line', 1, 2),
L('M280 186H520M280 214H446M280 242H492M280 284H352', 'accent', 2, 2.2),
L('M120 400H184M152 400V306L206 244', 'line', 3, 2),
L('M192 230L242 256L222 276Z', 'line', 3, 2),
L('M232 270L300 336', 'accent', 4, 1.4, true),
L('M596 400V362Q596 352 606 352H636Q646 352 646 362V400M646 366Q668 366 668 381Q668 395 646 393', 'line', 5, 2),
L('M611 340Q604 326 616 316M631 340Q624 326 636 316', 'soft', 5),
L('M680 400L688 362H728L736 400', 'line', 6, 2),
L('M708 362Q698 322 672 312M708 362Q722 320 748 306M708 362Q710 326 716 296', 'soft', 6, 1.8),
],
labels: [{ x: 400, y: 128, step: 2, anchor: 'middle', tone: 'accent' }],
texts: ['Your desk, your site'],
},
};
const TONE = {
line: 'color-mix(in oklab, var(--foreground) 78%, transparent)',
accent: 'var(--primary)',
soft: 'color-mix(in oklab, var(--foreground) 24%, transparent)',
};
/**
* <ScrollDrawing drawing="house" labels={['12.40 m', '7.80 m', 'Entrance']} />
* <ScrollDrawing strokes={[{ d: 'M0 0L100 100' }]} viewBox="0 0 100 100" />
*/
export function ScrollDrawing({ drawing = 'house', strokes, labels, viewBox = '0 0 800 500', start = 0.1, end = 0.55, label, className }: ScrollDrawingProps) {
const ref = useScrollProgress<HTMLDivElement>({ start, end });
const built = DRAWINGS[drawing];
const list = strokes ?? built.strokes;
const lab: DrawingLabel[] = strokes
? ((labels as DrawingLabel[] | undefined) ?? [])
: built.labels.map((x, i) => ({ ...x, text: (labels as string[] | undefined)?.[i] ?? built.texts[i] }));
const steps = list.map((s, i) => s.step ?? i);
const max = Math.max(1, ...steps);
// each step draws in a window of the progress, the windows overlap a little
const span = 1 / (max + 1);
const win = (step: number) => ({ a: round(step * span * 0.92, 4), w: round(span * 1.6, 4) });
return (
<div ref={ref} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true} className={cn('relative w-full', className)}>
<svg viewBox={strokes ? viewBox : '0 0 800 500'} className="block h-auto w-full overflow-visible">
{list.map((s, i) => {
const { a, w } = win(steps[i]);
return (
<path
key={i}
d={s.d}
pathLength={1}
fill={s.tone === 'soft' && /Z\s*$/.test(s.d) ? 'color-mix(in oklab, var(--primary) 7%, transparent)' : 'none'}
stroke={TONE[s.tone ?? 'line']}
strokeWidth={s.width ?? 1.8}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
style={{
strokeDasharray: s.dashed ? undefined : '1 1',
strokeDashoffset: s.dashed ? undefined : `calc(1 - clamp(0, (var(--kit-progress, 1) - ${a}) / ${w}, 1))`,
opacity: s.dashed || /Z\s*$/.test(s.d) ? `clamp(0, (var(--kit-progress, 1) - ${a}) / ${w} * 1.4, 1)` : undefined,
...(s.dashed ? { strokeDasharray: '5 6' } : null),
}}
/>
);
})}
{lab.map((x, i) => {
const { a, w } = win(x.step);
return (
<text
key={i}
x={x.x}
y={x.y}
textAnchor={x.anchor ?? 'start'}
className="text-[15px] font-medium tabular-nums"
fill={x.tone === 'accent' ? 'var(--primary)' : 'var(--muted-foreground)'}
style={{
opacity: `clamp(0, (var(--kit-progress, 1) - ${round(a + w * 0.7, 4)}) * 8, 1)`,
transform: `translateY(calc((1 - clamp(0, (var(--kit-progress, 1) - ${round(a + w * 0.7, 4)}) * 8, 1)) * 6px))`,
}}
>
{x.text}
</text>
);
})}
</svg>
</div>
);
}Frequent questions
How do I install Scroll drawing?
With aiw add @ai-website/scroll-drawing. The command puts components/ui/scroll-drawing.tsx into your project, the code is yours from then on. It brings along motion. It needs no further npm packages.
Which variants does Scroll drawing have?
4 variants, Blueprint, Route, Growth line and Workspace.
Can an AI assistant build with Scroll drawing?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Scroll drawing with aiw_components and install it with aiw_add @ai-website/scroll-drawing.