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:
| Element | Key attributes | Notes |
|---|---|---|
<line> | x1 y1 x2 y2 | Start and end point. Invisible until given a stroke. Diagonals are trivial, unlike HTML |
<rect> | x y width height rx ry | Anchored 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 r | Positioned by centre point |
<ellipse> | cx cy rx ry | Circle with independent horizontal and vertical radii |
<polyline> | points | Point list joined by straight lines; open shape, joints obey stroke-linejoin |
<polygon> | points | Same as polyline but auto-closed back to the first point. Any point list, not just regular polygons |
<text> | x y text-anchor dominant-baseline | No 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: blackandstroke: none, for every fillable shape including<polyline>. SVG ignorescolor-scheme, so the black default persists in dark mode. Always setfillexplicitly, to a colour or tonone fill="none"on the root<svg>inherits down to every child, likecoloron<html>fill="currentColor"orstroke="currentColor"resolves to the surrounding text colour. This is how icons follow semantic tokensfill-opacityandstroke-opacityfade each paint independently;opacityflattens 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 asoverflow: 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,bevelstroke-dasharraytakes alternating dash and gap lengths (10 10, or longer patterns like6 4 6 20that repeat). Commas and spaces are interchangeable separators- Dashes respect the cap style:
stroke-linecap="round"with a0-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-dashoffsetslides 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-miterlimitcaps the ratio of that extension to the stroke width, and the browser silently swaps the join tobevelonce 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: 10holds 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. Forndashes with equal gaps, both the dash and the gap measure the circumference divided by twicen
const circumference = 2 * Math.PI * radius;
const segment = circumference / (dashCount * 2);
circle.style.strokeDasharray = `${segment}px ${segment}px`;
- The no-JavaScript version is
pathLength. SetpathLength="100"on the circle and the same arithmetic runs on a scale of 100, so 25 dashes isstroke-dasharray: 2 2
Outlined text
<text>withfill="none"plus a stroke outlines the glyphs, but the centred stroke eats into the letterforms as it thickenspaint-order: strokepaints the stroke first so the fill covers its inner half, which reads as an outside-aligned outline. Addstroke-linejoin: roundfor soft corners. Widely supported on SVG- HTML text gets the same look with
-webkit-text-strokepluspaint-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-ordersupport; HTML wins when the text must wrap, select and lay out like real text.paint-orderon 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-indexis 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 needsxmlns. Standalone.svgdocuments 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.
| Command | Arguments | Draws |
|---|---|---|
M | x,y | Lift the pen and move, no ink |
L | x,y | Straight line to the point |
H / V | one number | Horizontal or vertical line, other coordinate inherited |
Q | cx,cy x,y | Quadratic curve, one control point |
C | c1x,c1y c2x,c2y x,y | Cubic curve, two control points; needed for S-bends and tight curves |
A | see below | Elliptical arc |
Z | none | Close back to the subpath start with a true join |
Zis not the same as drawing anLback to the start:Zjoins the ends (sobuttandsquarecaps meet cleanly),Lmerely overlaps themfillworks 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 fitx-axis-rotation: rotates the ellipse before fitting it, in degrees. No visible effect whenrxequalsry. Almost always0- 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-flagpicks the route:0short,1long.sweep-flagpicks the drawing direction:1clockwise,0counter-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,12means 12 right, 12 down). Relative paths slide as a unit when the leadingMchanges; atransformachieves the same without rewriting data - Coordinates chained after a command repeat it implicitly:
M 4,4 12,4 12,10impliesLfor the extra pairs. Common in exported SVGs; avoid when authoring by hand T(afterQ) andS(afterC) 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 likerord; 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. Aclasssitting 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. Keepclass, paint attributes and custom properties on the clone - The template needs a complete set of geometry attributes. Splitting them, for example
cxandcyon the template withron 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 withtransform getTotalLength()does not exist on<use>. It is not a shape, so the call throwsTypeError: 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 withnone, 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. SetmaskUnits="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
currentColoror defaults to black silently hides everything
Common mistakes
- Leaving
fillunset 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: visibleon the<svg> - Expecting a huge
rxto produce a pill; both radii clamp to their maximum and give an oval. Userxequal to half the height - Reaching for
z-indexto restack shapes; document order is what counts, so reorder the nodes document.createElement("circle")in dynamic code; withoutcreateElementNSand the SVG namespace the element never renders- Guessing arc flags by trial and error instead of choosing them:
large-arc-flagselects short or long route,sweep-flagselects direction - Overriding
rordon 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 gettingTypeError: 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-miterlimitof4bevelled 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