Auditing an icon set for consistency
A practical consistency audit for a large logo library, from viewBox, padding and color to naming and missing styles, with fixes made at the source.
6 min read
A logo library grows one icon at a time, from different designers, different source files and different export settings. Each addition looks fine on its own. A few hundred icons later, some files have a 24 by 24 viewBox and others 2500 by 2500, some logos touch the edges while others float in empty space, and a "mono" file turns out to contain three colors.
This audit catches those problems in two passes, an automated one for what a script can read and a visual one for what only eyes can judge, and then fixes issues where they come from so the next sync doesn't bring them back.
Write the spec before you audit
Without a written spec, every finding turns into a debate about whether it's really a problem. Half a page is enough:
- Every file has a square
viewBoxon the same coordinate scale, for example0 0 24 24. - No
widthorheighton the root element; size is set where the icon is used. - Artwork fills the viewBox up to a defined padding, for example at least 80 percent of the width or height.
- Mono files use exactly one color.
- No
<script>, event handlers, embedded raster images,<text>or external references. - Files stay under a size budget, with anything above it reviewed.
- File names follow one convention, and every icon exists in every style you promise.
Your numbers may differ; what matters is writing them down first.
The automated pass
This Node script walks a folder of SVGs and writes one CSV row per file. Open the CSV in a spreadsheet and sort by each column; outliers float to the top.
// node audit-svgs.mjs ./icons > audit.csv
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) yield* walk(path)
else if (entry.name.endsWith('.svg')) yield path
}
}
const root = process.argv[2]
console.log('file,bytes,viewBox,square,fixedSize,colors,ids,raster,text,script,external')
for (const file of walk(root)) {
const svg = readFileSync(file, 'utf8')
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? ''
const [, , w, h] = viewBox.split(/[\s,]+/).map(Number)
const colors = new Set((svg.match(/#(?:[0-9a-f]{3}){1,2}\b/gi) ?? []).map((c) => c.toLowerCase()))
console.log([
relative(root, file),
statSync(file).size,
viewBox,
viewBox !== '' && w === h,
/<svg[^>]*\s(?:width|height)=/.test(svg),
colors.size,
(svg.match(/\sid="/g) ?? []).length,
/<image\b/.test(svg),
/<text\b/.test(svg),
/<script\b|\son[a-z]+=/i.test(svg),
/(?:href|src)="https?:/.test(svg),
].join(','))
}
Regular expressions are blunt for XML, but good enough here: you're looking for files to open, not proving anything. What each column tends to reveal:
| Column | Typical finding | Fix |
|---|---|---|
viewBox |
0 0 2500 2500 from a raw export, or no viewBox at all |
Re-export on the set's grid; never ship without a viewBox |
square |
A wordmark-shaped 0 0 120 32 |
Reframe to a square, centering the mark |
fixedSize |
width="2500" makes the icon huge wherever CSS doesn't size it |
Remove the attributes |
colors |
A mono file with 3 colors, or a simple mark with 40 | Flatten to one color; check for gradients exported as many stops |
bytes |
60 KB for a simple coin | Usually auto-traced artwork or excessive precision; redraw or optimize |
ids |
Gradient and clip-path IDs like paint0_linear |
Prefix them per file, or they collide when icons are inlined |
raster |
A PNG embedded inside the SVG | Reject, or replace with real vector artwork |
text |
Live text that depends on installed fonts | Convert to outlines |
script, external |
Scripts, onload, links to remote files |
Reject outright |
Two of these deserve their own reading. Repeated IDs are behind the classic "gradients disappear when the same icon appears twice" bug, covered in SVG ID collisions. For what counts as too big, see how big should an icon SVG be.
The visual pass
Some problems only show when icons sit side by side. Build a contact sheet with every icon at 24 px and 48 px on both your light and dark surfaces, and look for:
- Padding. Some logos touch the edge; others sit small in the middle.
- Centering. Artwork shifted to one side, often left over from a cropped lockup.
- Visual weight. A solid black square beside a thin outline mark looks much heavier at the same size. Circles beside squares look small; optical balance explains why.
- Dark-surface legibility. Black and navy logos vanish on dark; white-filled "holes" show up as blobs.
- Wrong style in the wrong place. A full-color file in the mono folder, or a mono file that is just the branded one.
Padding and centering can be measured. Inline the icons on the contact sheet with a data-file attribute, then run this in the browser console:
for (const svg of document.querySelectorAll('svg[data-file]')) {
const box = svg.getBBox()
const vb = svg.viewBox.baseVal
const fill = Math.max(box.width / vb.width, box.height / vb.height)
const offset = (box.x + box.width / 2 - (vb.x + vb.width / 2)) / vb.width
if (fill < 0.8 || Math.abs(offset) > 0.03) {
console.log(svg.dataset.file, `fill ${fill.toFixed(2)}`, `offset ${offset.toFixed(3)}`)
}
}
getBBox() ignores stroke width, so outline icons read slightly smaller than they look. Visual weight has no reliable formula; squint at the sheet and note what jumps out.
Naming and coverage
Two quick checks catch most naming problems. First, run every file name against a regular expression for your convention and list the misses. Second, lowercase every name and look for duplicates. ETH.svg and eth.svg are two files on Linux and one file on the default macOS and Windows file systems, which leads to icons that silently overwrite each other on some machines. Naming conventions for large icon libraries covers how to avoid collisions in the first place.
For coverage, assuming one folder per style (branded/, mono/, background/), list which names are missing from which folder:
// Same imports and root as audit-svgs.mjs
const styles = ['branded', 'mono', 'background']
const found = new Map()
for (const style of styles) {
for (const name of readdirSync(join(root, style))) {
found.set(name, [...(found.get(name) ?? []), style])
}
}
for (const [name, has] of found) {
const missing = styles.filter((s) => !has.includes(s))
if (missing.length) console.log(name, 'missing', missing.join(' '))
}
Missing styles aren't always errors; some logos have no mono version yet. The interface should handle the gap honestly, for example by showing the branded version and saying so.
Fix at the source
If your icon files are generated, by an export script, a sync from a package or a build step, hand-editing the output is wasted work: the next run overwrites it. Fix problems where they enter.
- Artwork problems (padding, centering, colors, stray IDs) belong in the source artwork. For an open-source set, that means a fix upstream, where every user of the set benefits. The artwork on this site, for example, comes from the open-source web3icons library, so a broken logo is best fixed there.
- Data problems you can't fix upstream, such as a brand's spelling or a missing rank, go in a small overrides file in your own build, one entry per fix, each with a comment saying why it exists. Review it now and then; fixes that have landed upstream can go.
- Rules that must never break go into CI. Run the audit script on every change and fail the build on the hard rules: missing viewBox, scripts, external references, embedded rasters. Leave the judgment calls, like weight and padding, to a periodic visual review.
Keep the CSV from each run. Comparing it with the previous one shows exactly which new icons broke the spec.