Skip to content

SVG Drawing Fundamentals

A UI principle for coding agents. Also covers svg shapes, stroke, fill, paint order, svg arc, path commands, and 10 more.

Show all 16 aliases

svg shapes, stroke, fill, paint order, svg arc, path commands, svg text, stroke-linecap, stroke-linejoin, stroke-miterlimit, createElementNS, use element, getTotalLength is not a function, clipPath vs mask, dashed circle, seamless dash pattern

Core Philosophy

  • Get the static drawing right before anything moves. Most SVG animation bugs are drawing bugs that only became visible once the shape started changing
  • Everything in SVG is one pen and one paint model. Every primitive reduces to a path, and every path is filled, then stroked, in that order
  • Geometry lives in the viewBox coordinate system, not in pixels. Author against the coordinate system and let CSS size the element
  • Prefer the mechanism the format gives you (Z, pathLength, <use>, <mask>) over arithmetic you maintain by hand
  • svg-animation owns viewBox, preserveAspectRatio, self-drawing strokes, animated masks, transform origin and React integration. svg-visual-effects owns filters, gradients, patterns and curved text. This doc owns the shapes, paint model and path syntax those two animate and paint

Shape primitives

Inside <svg>, graphics children must be SVG elements, not HTML. The exception is <foreignObject>, the one container that hosts HTML inside SVG coordinates. Each primitive is positioned by its own geometry attributes:

ElementKey attributesNotes
<line>x1 y1 x2 y2Start and end point. Invisible until given a stroke. Diagonals are trivial, unlike HTML
<rect>x y width height rx ryAnchored at top-left. rx alone sets both radii; each clamps independently, rx to half the width and ry to half the height, so maxing both gives an oval. For a pill, set rx to half the height
<circle>cx cy rPositioned by centre point
<ellipse>cx cy rx ryCircle with independent horizontal and vertical radii
<polyline>pointsPoint list joined by straight lines; open shape, joints obey stroke-linejoin
<polygon>pointsSame as polyline but auto-closed back to the first point. Any point list, not just regular polygons
<text>x y text-anchor dominant-baselineNo automatic wrapping; break lines yourself with positioned <tspan>s, which also style sub-ranges like <span>. Best for labels in data viz

A shape with zero area (r="0", a rect with width="0") is degenerate: nothing renders, not even the stroke. A line whose ends coincide vanishes only under the default butt cap; round and square caps still paint at the point, the same mechanism behind the dotted-line trick below.

Fills and strokes

  • Presentation attributes (fill, stroke, stroke-width, dashes, opacity) are also CSS properties. A stylesheet declaration beats the attribute. Attributes are convenient for per-instance props in components; CSS for shared styling
  • The default paint is fill: black and stroke: none, for every fillable shape including <polyline>. SVG ignores color-scheme, so the black default persists in dark mode. Always set fill explicitly, to a colour or to none
  • fill="none" on the root <svg> inherits down to every child, like color on <html>
  • fill="currentColor" or stroke="currentColor" resolves to the surrounding text colour. This is how icons follow semantic tokens
  • fill-opacity and stroke-opacity fade each paint independently; opacity flattens the element first, then fades the result. A translucent stroke overlapping its own fill looks different under each. An alpha channel in the colour itself (oklch(0.7 0.1 250 / 0.5)) is equivalent to the per-paint properties

Strokes straddle the edge

  • Strokes are always centred on the geometry: half inside, half outside. There is no inside or outside alignment option
  • A shape drawn to the edge of the viewport gets its outer stroke half clipped, because browsers style inline <svg> viewports as overflow: hidden, the reverse of HTML (a standalone root SVG document stays visible). svg { overflow: visible } lets the stroke spill instead of shrinking the shape to compensate

Caps, joins, dashes

  • stroke-linecap: butt (default, ends exactly at the point), round, square (both extend past it)
  • stroke-linejoin: miter (default), round, bevel
  • stroke-dasharray takes alternating dash and gap lengths (10 10, or longer patterns like 6 4 6 20 that repeat). Commas and spaces are interchangeable separators
  • Dashes respect the cap style: stroke-linecap="round" with a 0-length dash renders dotted lines of perfect circles, because a round cap on a zero-length subpath paints a full circle centred on that point
  • stroke-dashoffset slides the pattern along the path. One long dash plus a huge gap gives a single segment you can move around a shape, the mechanism behind self-drawing strokes (see svg-animation)
  • Any dash pattern you animate needs a reduced-motion path that leaves the stroke fully painted. Removing the motion must never remove the mark

Miter limits and the round default

  • A miter join extends past the corner, and the sharper the angle the further it extends. stroke-miterlimit caps the ratio of that extension to the stroke width, and the browser silently swaps the join to bevel once the cap is exceeded
  • The initial value is 4, which bevels any angle below roughly 29 degrees. That is why a sharp arrowhead or a thin chevron looks blunted for no apparent reason
  • To keep a sharp corner sharp, raise the limit rather than redraw the shape. stroke-miterlimit: 10 holds miters down to roughly 11.5 degrees, and higher values hold sharper ones still
  • Round caps and round joins are the right default for the large majority of marks. They never spike, they never bevel unexpectedly, and they read as friendly rather than technical. Reach for miters only when the design specifically wants a hard point

Dash patterns that close cleanly on a circle

  • A dashed ring only looks right when the pattern divides the circumference exactly. Any hand-picked dash length leaves a visible seam where the pattern restarts, at the three o'clock point where every circle's path begins
  • The circumference is 2 * Math.PI * r. For n dashes with equal gaps, both the dash and the gap measure the circumference divided by twice n
const circumference = 2 * Math.PI * radius;
const segment = circumference / (dashCount * 2);
circle.style.strokeDasharray = `${segment}px ${segment}px`;
  • The no-JavaScript version is pathLength. Set pathLength="100" on the circle and the same arithmetic runs on a scale of 100, so 25 dashes is stroke-dasharray: 2 2

Outlined text

  • <text> with fill="none" plus a stroke outlines the glyphs, but the centred stroke eats into the letterforms as it thickens
  • paint-order: stroke paints the stroke first so the fill covers its inner half, which reads as an outside-aligned outline. Add stroke-linejoin: round for soft corners. Widely supported on SVG
  • HTML text gets the same look with -webkit-text-stroke plus paint-order: stroke. That prefix is a WebKit extension, standardised only by the compatibility spec, so every current engine including Firefox ships it, but there is no unprefixed form to fall back to
  • SVG wins when you need rounded joins or guaranteed paint-order support; HTML wins when the text must wrap, select and lay out like real text. paint-order on HTML text is newer and less supported than on SVG, so keep HTML stroke widths thin enough to stay legible without it

Stacking order

  • z-index is not a reliable stacking mechanism inside SVG. Browsers paint later siblings on top of earlier ones; document order is what counts
  • To restack, reorder the nodes in the markup, not the CSS. Reordering has no layout side effects, though it does change sequential focus order if any of the shapes are focusable

Creating elements from JavaScript

  • document.createElement("circle") silently produces an inert unknown HTML element. SVG elements need the namespaced call:
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
  • In markup the browser infers the namespace from the <svg> ancestor, and React does the same for JSX, so inline SVG never needs xmlns. Standalone .svg documents do, including files a browser opens directly or loads as an image

Path commands

<path d="..."> is one continuous pen: every command starts where the previous one ended, and the data must open with a Move.

CommandArgumentsDraws
Mx,yLift the pen and move, no ink
Lx,yStraight line to the point
H / Vone numberHorizontal or vertical line, other coordinate inherited
Qcx,cy x,yQuadratic curve, one control point
Cc1x,c1y c2x,c2y x,yCubic curve, two control points; needed for S-bends and tight curves
Asee belowElliptical arc
ZnoneClose back to the subpath start with a true join
  • Z is not the same as drawing an L back to the start: Z joins the ends (so butt and square caps meet cleanly), L merely overlaps them
  • fill works on open paths too; the browser fills as if the path were closed
  • Commas and whitespace between numbers are interchangeable and optional. Minified paths (M8 24L24 8) are valid; keep separators in hand-authored paths for readability, gzip erases the size difference

The arc command

A rx,ry x-axis-rotation large-arc-flag sweep-flag x,y

Arcs connect the current point to an end point along a hypothetical ellipse, and every parameter is required:

  • rx ry: the ellipse radii. Smaller radii cut deeper arcs, bigger radii flatter ones. Radii too small to bridge the two points are scaled up proportionally until they fit
  • x-axis-rotation: rotates the ellipse before fitting it, in degrees. No visible effect when rx equals ry. Almost always 0
  • Two flags resolve the ambiguity: a fitted ellipse offers up to four candidate arcs (two ellipse placements, each cut into a short and a long route). large-arc-flag picks the route: 0 short, 1 long. sweep-flag picks the drawing direction: 1 clockwise, 0 counter-clockwise
  • Arcs and Bézier curves are complementary: Béziers for organic swoops, arcs for the symmetric curvature of circles and rings (progress rings, dial ticks, rounded connectors)

Path sugar

  • Lowercase commands are relative to the current pen position (l 12,12 means 12 right, 12 down). Relative paths slide as a unit when the leading M changes; a transform achieves the same without rewriting data
  • Coordinates chained after a command repeat it implicitly: M 4,4 12,4 12,10 implies L for the extra pairs. Common in exported SVGs; avoid when authoring by hand
  • T (after Q) and S (after C) continue a curve while omitting the first control point, which is derived by reflecting the previous one. That reflection is what keeps chained curves kink-free; hand-matching angles rarely does

Repeating with use

  • <use href="#id"> clones a template element, typically a <path> in <defs>, so shared geometry lives once in the markup instead of being copy-pasted per instance. A framework variable dedupes source code but not server-rendered HTML; <use> dedupes both
  • Geometry belongs on the template, presentation on each <use>. A <use> cannot override geometry attributes like r or d; vary size and position per clone with transforms
  • The clone renders in a closed shadow tree: outside CSS selectors cannot reach into it. Styling crosses the boundary only through inherited properties (fill, stroke, color) and CSS custom properties, so parameterise clones with variables

Three failure modes follow from that split, and none of them announce themselves:

  • Put the class on the <use>, not on the template. A class sitting on the template shape resolves in some engines and not others, so an illustration can look correct in one browser and unstyled in another. MDN's own guidance is that cloned nodes are not exposed and that properties are not guaranteed to reach them unless they inherit. Keep class, paint attributes and custom properties on the clone
  • The template needs a complete set of geometry attributes. Splitting them, for example cx and cy on the template with r on each clone, renders nothing at all in any engine, because the clone contributes no geometry. Give the template every attribute it needs and vary the clones with transform
  • getTotalLength() does not exist on <use>. It is not a shape, so the call throws TypeError: elem.getTotalLength is not a function. Query the source <path> in <defs> and measure that instead
const template = document.querySelector("#line-template");
const length = template.getTotalLength();

Clipping versus masking

SVG has two elements for showing part of a shape, and they are not interchangeable. Default to <mask> inside SVG; the CSS clip-path property remains the right tool on HTML boxes.

  • A clip is binary. A pixel is inside the clipping region or outside it. There is no partial opacity, no soft edge, no fade. A mask maps greyscale to alpha, so it can do all three
  • A clip always uses fill, never stroke. The clipping region comes from the referenced shapes' fill areas, so a path with fill="none" and a stroke clips to nothing. A mask can clip by stroke: fill the path with none, stroke it white, and the mask takes the shape of the line
<mask id="squiggle">
  <path
    d="M 2,16 C 16,32 16,0 30,16"
    fill="none"
    stroke="oklch(1 0 0)"
    stroke-width="3"
  />
</mask>
  • A clip cannot be inverted. The region is the union of the referenced shapes, so there is no way to say "everything except this circle". You can punch a bounded hole by authoring one path whose outer subpath encloses the inner one and setting clip-rule: evenodd, but that collapses the moment the cut-out has to be a separate shape or move on its own. A mask expresses it directly: paint white, then paint the cut-out black on top
  • Cut-out shapes such as crescents, notched cards and pop effects are therefore mask work. See svg-animation for the animated versions

Mask gotchas

Beyond the transform-order basics covered in svg-animation, two failure modes cost afternoons:

  • Strokes are masked, never applied after masking. The stroke paints on the original geometry first, then the mask cuts through stroke and fill alike, so you cannot outline the silhouette a mask produced. To outline a masked composite, either rebuild the shape as one <path> (arcs handle most cut-out shapes) or draw each contributing edge as its own stroked shape under a mask that reveals exactly the final silhouette. A semi-transparent stroke applied to both the shape and the mask content can also soften the cut edge
  • Masked axis-aligned lines vanish. The default mask region is sized from the target's bounding box, and a purely horizontal or vertical <line> has a zero-area box, so the mask region collapses and the element disappears. Set maskUnits="userSpaceOnUse" on the <mask>; it fixes the collapse without changing normal rendering
  • Mask content is read by luminance by default: fill it white to show, black to hide. A mask whose content inherits currentColor or defaults to black silently hides everything

Common mistakes

  • Leaving fill unset and getting a solid black blob, because every fillable shape defaults to a black fill and no stroke
  • Fighting a clipped stroke by shrinking the shape instead of setting overflow: visible on the <svg>
  • Expecting a huge rx to produce a pill; both radii clamp to their maximum and give an oval. Use rx equal to half the height
  • Reaching for z-index to restack shapes; document order is what counts, so reorder the nodes
  • document.createElement("circle") in dynamic code; without createElementNS and the SVG namespace the element never renders
  • Guessing arc flags by trial and error instead of choosing them: large-arc-flag selects short or long route, sweep-flag selects direction
  • Overriding r or d on a <use> clone; geometry only comes from the template, so vary clones with transforms and inherited paint
  • Styling a <use> clone through a class on the template shape, which resolves in some engines and not others; put presentation on the clone
  • Calling getTotalLength() on a <use> element and getting TypeError: elem.getTotalLength is not a function; measure the source path instead
  • Picking a dash length by eye for a dashed ring, which leaves a seam at the three o'clock point where the pattern restarts
  • Redrawing a sharp corner because it renders blunt, when the default stroke-miterlimit of 4 bevelled it and a higher limit keeps it
  • Reaching for <clipPath> to cut a hole out of a shape; a clip region cannot be inverted, so that is mask work

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "svg-drawing-fundamentals" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems