web3icons

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 viewBox on the same coordinate scale, for example 0 0 24 24.
  • No width or height on 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.

More in Icon design

All guides