<path d="M 100 40 C 60 0 0 40 100 140 C 200 40 140 0 100 40 Z" />Can you read that at a glance? I couldn't. I knew
Mwas "move" but the rest was a blur. TheCtakes three coordinate pairs — why three?Ztakes none — what does it close? Short commands likeHandSalways turned into "I'll look that up later" and I spent years not looking it up. I finally built a tiny visual editor for it, and thedstring stopped being a mystery after about two days. Here's what I learned.
📦 GitHub: https://github.com/sen-ltd/svg-path-editor
🔗 Demo: https://sen.ltd/portfolio/svg-path-editor/
What it does:
- Paste any
dattribute → the canvas renders it and overlays draggable anchors (green) and cubic/quadratic control points (orange). - Drag a node → the path updates and the
dtextarea re-emits a clean, rounded string. - Output is normalized to absolute
M / L / C / Q / Zonly. Round-tripping through parser + serializer is stable. - Svelte 5 + TypeScript + Vite. Zero runtime deps beyond Svelte.
The rest of this article is less "here's how you use it" and more "things the SVG path grammar does that nobody warned me about."
The d attribute is a sequence of pen commands
A d string is a list of instructions for an imaginary pen.
| Command | Meaning | Coord pairs |
|---|---|---|
M x y |
Lift the pen, move to (x, y). No drawing. | 1 |
L x y |
Line from current point to (x, y) | 1 |
H x |
Horizontal line to x (y stays) | 0.5 |
V y |
Vertical line to y (x stays) | 0.5 |
C x1 y1 x2 y2 x y |
Cubic bezier with controls (x1,y1) and (x2,y2), endpoint (x,y) | 3 |
S x2 y2 x y |
Cubic bezier with x1 auto-reflected from the previous C | 2 |
Q x1 y1 x y |
Quadratic bezier with one control, endpoint (x,y) | 2 |
T x y |
Quadratic bezier with x1 auto-reflected from the previous Q | 1 |
A rx ry rot large sweep x y |
Elliptical arc | 3.5 |
Z |
Straight line back to the current subpath's start. Closes the subpath. | 0 |
Uppercase = absolute coordinates. Lowercase = relative to the current point. M and m behave differently enough that you should almost treat them as separate commands.
Here's the first observation that made the editor possible: H, V, S, T, and all relative commands are just shorthand for M/L/C/Q. If you normalize everything to absolute M/L/C/Q/Z up front, the number of node shapes you have to draw drops from ten to five, and the dragging code stops caring which letter the user originally typed.
Gotcha: M x y x y is "M then L"
This one bit me immediately.
M 10 10 20 20 30 30
The natural read is "three moveto's in a row." The spec says otherwise: only the first pair is M, the rest are implicit lineto's.
If a moveto is followed by multiple pairs of coordinates, the subsequent pairs are treated as implicit lineto commands.
— SVG 2 Paths §8.3.2
Other commands (L, C, Q) do implicitly repeat themselves — M is the odd one out, substituting a different command for its own repetitions. The parser carries a firstIteration flag just to handle this:
let firstIteration = true;
while (/* coords still coming */) {
if (upper === 'M') {
const p = readPoint(cursor);
const abs = isRelative ? rel(currentPoint, p) : p;
if (firstIteration) {
out.push({ kind: 'M', p: abs });
subpathStart = abs;
} else {
out.push({ kind: 'L', p: abs }); // ← the trap
}
currentPoint = abs;
}
// ... other commands
firstIteration = false;
}
Gotcha: S and T reflect the previous control point
S is "another cubic bezier, but reuse the previous C's second control point, reflected around the current point." Concretely:
M 0 0 C 10 0 20 10 30 10 S 50 20 60 20
^^^^^
previous c2 = (20, 10)
reflect through (30, 10)
→ implicit new c1 = (40, 10)
Reflection is algebraically trivial:
function reflect(around: Point, p: Point): Point {
return { x: 2 * around.x - p.x, y: 2 * around.y - p.y };
}
The spec also says: if the previous command wasn't a C or S (or Q/T for T), the reflection point degenerates to the current point (because there's nothing to reflect). Forgetting that rule ships broken paths when someone mixes L and S.
The parser keeps lastCubicC2 and lastQuadC as nullable cursors and resets them to null whenever a non-bezier command runs, so S and T can look them up without worrying about stale state.
Gotcha: number separators
"Numbers are separated by whitespace or commas" almost covers it — except the spec also allows signs and decimal points to act as separators to save bytes:
M.5.5L1,1 ← valid. Equivalent to M 0.5 0.5 L 1 1
M-1-2L3,4 ← valid. Equivalent to M -1 -2 L 3 4
Modern minifiers emit strings like this and browsers accept them silently. Writing a tokenizer that splits on whitespace and commas won't get you there. My number reader is hand-rolled as [sign][int part][. + frac]?[e + exp]?:
function readNumber(cursor: Cursor): number {
skipWsAndCommas(cursor);
const start = cursor.pos;
const src = cursor.src;
if (src[cursor.pos] === '+' || src[cursor.pos] === '-') cursor.pos++;
while (/\d/.test(src[cursor.pos])) cursor.pos++;
if (src[cursor.pos] === '.') {
cursor.pos++;
while (/\d/.test(src[cursor.pos])) cursor.pos++;
}
// exponent handling...
return parseFloat(src.slice(start, cursor.pos));
}
It's 20 lines. Worth it vs. regex gymnastics.
Gotcha: Z resets the current point
Z doesn't just close the subpath — it teleports the current point back to the last M. Otherwise:
M 10 10 L 20 20 Z M 30 30 l 5 0
^^^^^^
must resolve to (35, 30)
because after Z current point is (30, 30)
A parser that doesn't reset the current point on Z will produce a subtly wrong (25, 20) for that last l 5 0. I caught this one in tests — not by reading the spec first.
The drag loop: clientX/Y → SVG user coords
With parsing sorted, rendering is free (<path d={dString}>). The editor's only non-obvious bit is converting mouse coordinates to SVG user-space coordinates during a drag.
Browsers hand you the inverse matrix for free:
function clientToSvg(clientX: number, clientY: number): Point {
const ctm = svgEl.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const inv = ctm.inverse();
const pt = new DOMPoint(clientX, clientY).matrixTransform(inv);
return { x: pt.x, y: pt.y };
}
getScreenCTM() returns the page-pixel → SVG-user-space transform, including any viewBox scaling and parent transforms. Invert it and matrixTransform does the rest. This is one of those APIs that has been there for fifteen years and hardly anyone mentions it.
For the drag itself, I use pointer capture so a drag keeps tracking the pointer even when it leaves the node:
function startDrag(e: PointerEvent, t: DragTarget) {
(e.target as Element).setPointerCapture(e.pointerId);
dragging = t;
}
setPointerCapture + pointermove + pointerup also unifies mouse and touch with one code path — no separate touch handlers.
Why normalize everything to absolute M/L/C/Q/Z
The editor eats H, V, S, T, and relative coordinates and spits out only absolute M/L/C/Q/Z. Trade-off:
-
Wins: one node = one AST entry.
CandSlook identical in the UI (an anchor plus two handles). Output is stable across round-trips —git diffon two saves makes sense. -
Loses: you can't preserve the original formatting.
H 100comes back asL 100 0. A minifieddloses its minification.
For a learning and editing tool that's the right call. A minifier-preserving editor is a different design.
Why I don't support arcs (A)
A rx ry x-axis-rotation large-arc-flag sweep-flag x y is the one SVG command that can't be losslessly reduced to a handful of Bezier segments. You need 2–4 cubic segments to approximate an elliptical arc, and the quality of the approximation depends on the sweep angle. That forces a design choice:
- Treat an arc as one draggable node? Then you need a completely different gesture for editing it (radius? rotation?). Big divergence from the M/L/C/Q uniform UI.
- Explode it into Beziers up front? Then the user loses the arc as a concept — re-saving is lossy.
I punted: the parser throws on A, with a helpful message.
case 'A':
throw new Error(
'Arc commands (A/a) are not supported in this editor — ' +
'convert to cubic béziers first.',
);
Silent data loss is worse than an explicit "please flatten this first." Tools like svgo already do the flattening.
Round-trip tests catch every normalization bug
The cleanest way I've found to test a parser + serializer pair is to verify that parse → serialize → parse → serialize gives the same output the second time. If round-tripping is stable, both sides speak the same dialect.
it.each([
'M 0 0 L 10 10 Z',
'M 100 100 C 150 100 200 150 200 200 Z',
'M 0 0 Q 50 50 100 0',
])('serialize(parse(%s)) stabilizes', (d) => {
const once = serializePath(parsePath(d));
const twice = serializePath(parsePath(once));
expect(twice).toBe(once);
});
That one test exercises four different normalization paths (H→L, relative→absolute, S reflection, number rounding) in every case. Combined with a dozen targeted tests for individual gotchas, I have 18 green assertions covering the whole pipeline.
Svelte 5 notes
Two things surprised me on the framework side:
-
Mutating
$stateobjects doesn't always trigger reactivity. When I wrotepath[i].p.x = newX, the UI didn't re-render because the top-level reference didn't change.path = [...path]after each mutation fixes it. Svelte 5's runes are not deep-reactive on arrays for good performance reasons; you opt in with a spread. -
bind:this={svgEl}is shorter thanuseRef. One line, fully typed asSVGSVGElement | null. Small quality-of-life win.
Bundle size was not something I measured tightly, but another portfolio app on the same stack (Svelte 5 + Vite) ships a 19 kB gzipped bundle for a similarly-sized app. This one has fewer features, so probably less.
Takeaways
- The SVG
dattribute is a pen-command list. M/L/C/Q/Z are the real primitives; H/V/S/T are shorthand. - The landmines are:
M x y x ysilently becomes M+L, S/T reflect, Z resets the current point, and numbers can split on signs and decimals. - Normalize everything to absolute M/L/C/Q/Z before editing. The UI code gets dramatically simpler.
- Arcs can't be losslessly flattened. It's OK to reject them with an error and ask the user to pre-convert.
- Use
getScreenCTM().inverse()+setPointerCapture()for the drag loop. Both are stable browser APIs you don't see in tutorials much.
If d="M 100 40 C 60 0 0 40 100 140 C 200 40 140 0 100 40 Z" reads cleanly now, the entry cost to CSS path animations (<animate>, offset-path) dropped a notch. That's the real win.
Repository: https://github.com/sen-ltd/svg-path-editor

Top comments (0)