Constructing SVG Icons in Code: viewBox, currentColor and the Rest Is Detail
An icon that looks right in the pull request and hardcodes its fill will be invisible in dark mode, and nobody will connect the two events.
Two attributes decide whether an icon survives your design system
Most reviews of agent-written SVG focus on whether the shape looks right.
That is the least durable property in the file. An icon that looks correct in
the pull request and hardcodes fill="#000000" will be invisible in
dark mode a month later, and nobody will connect the two events.
The attributes that decide an icon's fate are viewBox and
currentColor. Everything else — path elegance, node count,
whether the curve is a true arc — is refinement. These two are structural,
and an agent gets them wrong in a specific, predictable way.
viewBox is the contract; width and height are a suggestion
A viewBox declares the coordinate system the paths are drawn
in. It is what makes the graphic scalable: the browser maps that box onto
whatever space the element occupies. Fixed width and
height attributes on the root <svg> override
that flexibility with a specific pixel size, which is exactly what you did not
want.
<!-- Locked to 24px. Set font-size on the parent and nothing happens. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="#111827">
<!-- Scales with the box it is placed in; inherits the text colour. -->
<svg viewBox="0 0 24 24" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.75">
The second form is the one that behaves. 1em ties the icon to
the surrounding type scale, so an icon beside a heading grows with the heading
without a single line of component code. If you take one habit from this
article, take that one.
currentColor is how an icon joins the cascade
currentColor resolves to the computed color of the
element, which means the icon inherits from whatever context it lands in:
theme, hover state, disabled state, an alert's error colour. A hardcoded hex
value opts out of all of it and forces every consumer to override.
Agents hardcode colours because the drawing tools and reference material
they have learned from hardcode colours. Editor exports are full of
#000000. It is not a reasoning failure so much as a convention
that has to be stated, and stating conventions the model cannot infer is
precisely what your repository's instruction file exists for — see
encoding architecture
constraints for the general shape of that argument.
An icon set is either stroke-based or fill-based. Mixing them is the most visible inconsistency in a UI and the easiest to introduce one icon at a time. Write which one you use into the instruction file, with the stroke width, and the agent will hold the line better than a human reviewer does.
Stroke width does not scale the way you expect
Scale a stroked icon down and the stroke scales with it, so a 1.75px stroke
at 24px becomes a 1.17px stroke at 16px — noticeably lighter beside its
neighbours. The fix is vector-effect="non-scaling-stroke", which
keeps the rendered stroke constant regardless of the transform.
Use it deliberately rather than everywhere. A set designed at one size and displayed at one size does not need it. A set that appears at 16, 24 and 40 does, or you accept that the small sizes read lighter.
Accessibility is two attributes and a decision
The decision is whether the icon carries meaning. A chevron beside the word "Next" carries none — the word does. A bare trash-can button carries all of it.
<!-- Decorative: the adjacent text already says it. -->
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<!-- Meaningful: this is the only label the control has. -->
<svg viewBox="0 0 24 24" role="img" aria-label="Delete item">
Agents default to labelling everything, which produces a screen reader that announces "chevron right" after every link. Over-labelling is a real accessibility defect, not a harmless excess, and it is worth saying so in the instructions.
What agent-written SVG gets wrong, in order of frequency
Hardcoded colours, as above. Then nested <g transform="translate(...)">
wrappers that exist because the paths were drawn in the wrong coordinate space
and shifted afterwards — harmless to render, miserable to edit, and a
sign the geometry was not reasoned about. Then editor cruft: id
attributes referencing nothing, empty <defs>,
xmlns:xlink declarations with no xlink in the file.
Then paths with fifteen decimal places, which triple the file size and say
nothing.
A validator beats a reviewer
All of the above is mechanically checkable, which means it should be checked mechanically. This runs in well under a second across a full icon set.
import re, sys, pathlib
FAILS = []
def check(p):
s = p.read_text(encoding="utf-8")
if "viewBox" not in s:
FAILS.append((p, "no viewBox"))
if re.search(r'(fill|stroke)="#[0-9a-fA-F]{3,8}"', s):
FAILS.append((p, "hardcoded colour; use currentColor"))
if re.search(r'<svg[^>]*\swidth="\d+"', s):
FAILS.append((p, "pixel width on root svg; use 1em"))
if "aria-label" not in s and 'aria-hidden="true"' not in s:
FAILS.append((p, "neither labelled nor explicitly decorative"))
if re.search(r"\d\.\d{5,}", s):
FAILS.append((p, "excessive path precision"))
for p in sorted(pathlib.Path("assets/icons").glob("*.svg")):
check(p)
for p, why in FAILS:
print("%s: %s" % (p, why))
sys.exit(1 if FAILS else 0)
Five rules, each of which encodes a decision your team has already made. Add one every time a review comment repeats itself. The value is not that the script is clever; it is that the review conversation stops happening.
Inline, sprite or file: pick by how the icon is used
The same icon can be delivered three ways, and agents pick whichever appeared in the last file they read rather than the one that suits the situation.
Inline in the component is right when the icon needs to react —
animate a path, change stroke on hover, swap a shape by state. You pay for it
in markup size, repeated per instance. An external file referenced by
<img> is right for anything decorative and static, and it
gets cached, but it cannot inherit currentColor at all, which
undoes half of the previous section. A sprite sheet with
<use> is the middle path: one request, colour inheritance
intact, one definition per icon.
If the icon changes with state, inline it. If it must follow the theme, use a sprite. If it is decorative, static and large, use a file. State the rule in the instruction file and the choice stops being arbitrary.
Optimisation is a lossy operation on the parts you care about
Running an optimiser over agent-written SVG is nearly always worth it, and
it is not free. Aggressive settings merge paths, drop id
attributes something else was referencing, collapse groups that a transition
depended on, and round coordinates until a hairline gap opens between two
shapes that were flush.
Keep a conservative configuration in the repository rather than accepting tool defaults, and re-render the contact sheet after the optimiser runs rather than assuming a byte reduction was harmless. The specifics of which passes to disable, and what each one breaks, get their own treatment in the article on optimising vector assets.
Reviewing an icon in ninety seconds
Open the file. Is there a viewBox, and is it square and
sensible — 0 0 24 24 rather than
0.5 1.25 23.1 22.8, which means the geometry was drawn somewhere
else and nudged? Is the colour currentColor or absent? Is the
root sized in em rather than pixels?
Then: one stroke width, matching the set. No nested transform wrappers. No
id attributes unless a <use> needs them. Either
aria-hidden or aria-label, matching whether the icon
carries meaning. Coordinates at two decimal places, not fifteen.
Finally, look at it in the contact sheet beside its neighbours, in both themes. Every item on that list except the last is enforced by the linter above, which is precisely why the last one is the only part a person should be spending time on.
Optical correction is the thing an agent cannot compute
Mathematical centring and optical centring are different, and icons are judged optically. A triangle centred by its bounding box looks left-heavy, because its visual mass sits toward the base — which is why every play button you have ever seen is nudged a pixel or two to the right. A circle needs to be slightly larger than a square to read as the same size beside it.
An agent placing shapes by coordinate will centre them correctly and produce a set where the play icon looks off. This is not a prompting failure; it is a perceptual judgement made from a rendering, and the agent is not looking at a rendering. The contact sheet from the previous section is how you catch it, and the correction is a manual nudge you then record in the file so nobody undoes it.
<svg viewBox="0 0 24 24" width="1em" height="1em"
fill="currentColor" aria-hidden="true">
<!-- Optically centred: shifted +0.75 on x. Mathematical centre
reads left-heavy against the square-bounded icons. -->
<path d="M8.75 5.5v13l10-6.5z"/>
</svg>
The comment is doing real work. Without it, the next person to touch the file — agent or human — sees an asymmetric shape in a symmetric frame, assumes an error, and centres it.
Baseline alignment, the other invisible defect
An icon sized in em and dropped inline beside text sits on the
text baseline, which puts it slightly too low, because a glyph's visual centre
is above its baseline. The usual correction is a small negative vertical
alignment, or setting the icon and its label in a flex row with
align-items: center and letting the box model handle it.
Whichever you choose, choose once and put it in the icon component rather than in the twelve places icons appear. Agents will faithfully reproduce whatever the surrounding code does, so a codebase with three different alignment hacks will grow a fourth.
What to write in the instruction file
Everything in this article that is a decision rather than a fact belongs in
your repository's agent instructions, stated once: the stroke width, whether
the set is stroke or fill, the em sizing rule, the
inline-versus-sprite-versus-file rule, the labelling convention, and the
precision limit. Six lines.
Those six lines are worth more than any amount of review commentary, because they are read before the work happens rather than after. The linter then catches the cases where they were read and not followed, which is a much smaller set than the cases where they were never stated.
Why this list is short on aesthetics
Nothing here tells you whether an icon is good. That is deliberate, and it is the trade I would defend if you pushed back on this article: every rule above is mechanical because mechanical rules are the ones that survive delegation.
An agent can be told to use currentColor and will comply
indefinitely. It cannot be told to make an icon feel like the rest of the set,
because that judgement is made by looking, and the looking has to be done by
someone with the set in front of them. Loading the review with both kinds of
feedback means the mechanical half crowds out the perceptual half, and the
perceptual half is the only one a person was needed for.
So: automate the structural rules completely, and spend the recovered attention on the contact sheet. That is the whole argument. The linter is not there because the rules are important — they are minor — it is there because the rules are boring enough to displace the important conversation if you let them.
Judge agent-written SVG on viewBox and
currentColor before you judge the shape: those two decide whether
the icon scales with its type and follows your theme, and both are wrong by
default because editor exports taught the model otherwise. Set
width="1em", pick stroke or fill for the whole set and write it
down, label meaningful icons and hide decorative ones, and enforce all of it
with a twenty-line linter rather than repeating yourself in review.