8 rules for drawing a 300-icon line set that actually looks consistent (and a 30-line validator)
Most "inconsistent UI" complaints trace back to icons. Buttons look fine, typography looks fine, but one icon has a 1.5px stroke, the next has 2px, one is drawn on a 20px grid and another on 24, and the eye reads the who
Most "inconsistent UI" complaints trace back to icons. Buttons look fine, typography looks fine, but one icon has a 1.5px stroke, the next has 2px, one is drawn on a 20px grid and another on 24, and the eye reads the whole screen as slightly off. If you've ever pulled a "close enough" icon from a second library to fill a gap, you've shipped this.
I just finished drawing a 300-icon line set from scratch (generated from code, which is the point of this post), and the rules below are what I had to settle before icon #1 and never break again. They apply whether you draw by hand in Figma or generate SVG from Python like I did.
1. Pick one grid and one padding, forever
24×24 is the de-facto standard for line icons (Material, Lucide, Feather all use it). Inside that, keep a 2px safe area: nothing touches the edge except deliberate optical overshoots like a map pin's point. Most of my shapes live in x/y 3..21.
Why 2px and not 1? Because a 2px stroke centred on x=2 bleeds to x=1, and when the icon is rendered at 16px that becomes sub-pixel fuzz against the container edge.
2. One stroke width, round caps, round joins
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
Put this on the <svg> root, not on every path. Three reasons:
- Changing the whole set to a "light" variant is one find-and-replace (
stroke-width="1.5"). -
currentColormeans the icon follows your text colour — dark mode is free. - Your paths stay tiny:
<path d="M3 11l9-8 9 8"/>and nothing else.
Mixing round and square caps in one set is the most common tell that icons came from two sources.
3. Circles are always r in {1, 1.5, 2, 3, 4, 6, 9, 10}
Decide a small radius scale and stick to it. Concentric circles (target, lens, donut chart) look balanced when radii step by 4 (2, 6, 10). A dot indicator is r=1 filled, or — my preference — a zero-length line, which inherits the round cap and the stroke colour automatically:
<line x1="12" y1="18" x2="12.01" y2="18"/>
That trick is how smartphone, more-horizontal, cloud-snow and keyboard dots in my set stay the exact same diameter as every stroke end.
4. Build "modifier" icons by composition, not redrawing
file, file-plus, file-minus, file-check, file-x, file-code, file-image, file-zip all share one outline path. The modifier is a 6×6 glyph centred at (12, 15). Same for folder-*, user-*, shield-*, cloud-*, battery-*. If you generate SVG from code this is literally a function:
def file_icon(*extras):
return [FILE_OUTLINE, FILE_FOLD, *extras]
ICONS["file-plus"] = file_icon(L(12,12,12,18), L(9,15,15,15))
ICONS["file-check"] = file_icon(PL("9,15 11,17 15,13"))
Users feel the family resemblance even if they can't name it.
5. "Off" states: one 45° slash, top-left to bottom-right
bell-off, wifi-off, mic-off, eye-off, camera-off, flash-off: always a single line from roughly (3,3) to (21,21), and the underlying icon is cut, not just overlaid, where the slash crosses it. The cut is 1 stroke-width of gap on each side. Overlaying without cutting looks like a bug at 16px.
The exception is phone-off, where a slash fights the handset's own diagonal. Use an × badge in the free top-right corner instead. Knowing when to break the rule is part of the rule.
6. Test at 16px and in dark mode before you call it done
Render the contact sheet twice: once at 32px on white, once at 16px on #111. The 16px/dark pass catches:
- strokes that merge (two parallel lines closer than 2px apart),
- details smaller than 1.5px (they vanish),
- unbalanced optical weight (a filled polygon next to a thin outline).
I generate the sheet as a plain HTML grid and screenshot it headless with Playwright; the whole validate-render-eyeball loop is under ten seconds, which is what makes fixing 20 icons in one pass painless instead of a chore.
7. Validate mechanically
A 30-line script catches most regressions before you look:
import re, xml.etree.ElementTree as ET
def validate(svg_text):
root = ET.fromstring(svg_text) # well-formed
assert root.get("viewBox") == "0 0 24 24" # one grid
assert "<text" not in svg_text and "<image" not in svg_text
assert not re.search(r'fill="(?!none|currentColor)', svg_text) # no baked colours
for n in map(float, re.findall(r"-?\d+(?:\.\d+)?", svg_text)):
assert -1 <= n <= 25 # nothing wildly off-canvas
Add it to CI if the icons live in a repo. Nothing enforces "one stroke width" like a failing test.
8. Ship a sprite and a JSON map, not just files
Developers rarely want 300 files. Give them:
-
sprite.svgwith one<symbol id="name">per icon →<svg><use href="sprite.svg#camera"/></svg> -
icons.jsonmappingname → inner markupso they can build a React/Vue component in five lines.
Designers want the opposite: individual SVGs in category folders, plus PNG exports for Canva/Keynote where SVG support is patchy.
If you'd rather not draw 300 of these yourself, the set I built following exactly these rules is Lineset 300 — 300 icons, 12 categories, SVG + sprite + JSON + PNG, commercial licence, $15. The generator approach above is also how I made a spreadsheet pack earlier this year: define the data once, render deterministically, validate, eyeball, ship.
What's your rule for icon consistency that I missed? I'd add it to the validator.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.