DEV Community

SEN LLC
SEN LLC

Posted on

I Finally Understand the SVG d Attribute — Because I Built an Editor for It

<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 M was "move" but the rest was a blur. The C takes three coordinate pairs — why three? Z takes none — what does it close? Short commands like H and S always 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 the d string 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/

SVG Path Editor — a heart preset. Green circles are anchors, orange circles are bezier control points, the d attribute on the right updates live as you drag.

What it does:

  • Paste any d attribute → the canvas renders it and overlays draggable anchors (green) and cubic/quadratic control points (orange).
  • Drag a node → the path updates and the d textarea re-emits a clean, rounded string.
  • Output is normalized to absolute M / L / C / Q / Z only. 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Reflection is algebraically trivial:

function reflect(around: Point, p: Point): Point {
  return { x: 2 * around.x - p.x, y: 2 * around.y - p.y };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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. C and S look identical in the UI (an anchor plus two handles). Output is stable across round-trips — git diff on two saves makes sense.
  • Loses: you can't preserve the original formatting. H 100 comes back as L 100 0. A minified d loses 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.',
  );
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Mutating $state objects doesn't always trigger reactivity. When I wrote path[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.
  2. bind:this={svgEl} is shorter than useRef. One line, fully typed as SVGSVGElement | 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 d attribute 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 y silently 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)