Blode Icons

How these icons are drawn

An icon set is a vocabulary, and a vocabulary only works if every word is spelled the same way. What follows is the house spec: the facts about the art that the code depends on, written down so an adjustment can be told apart from drift.

The grid and the base stroke

Every outline icon is drawn on a 24x24 grid with a 2px stroke. That is the base tier, and it is what strokeWidth means by default. Strokes are currentColor, so an icon takes the colour of the text around it and needs no theme wiring.

Most icons also ship a filled companion, generated by expanding that 2px outline into a fill. The two are drawn to the same silhouette, so swapping style in a toggle does not move the mark.

Optical thinning is a tier, not drift

A dense glyph cannot hold the base tier everywhere. Where a small interior mark sits inside a container that is also 2px, the two adjacent strokes close the light between them and the glyph reads as a blob at 16px. The interior is therefore drawn thinner on purpose.

An undocumented adjustment is indistinguishable from drift, so the thinned values are named. A lint rule that flags “stroke width is not 2” exempts them.

TierUse
2The base. Every outline stroke, unless something below applies.
1.8An interior mark inside a 2px container that would otherwise clog.
1.5 to 1.9The same adjustment, tuned per glyph where 1.8 was not enough.
0.75, 0.5Hairlines: text ruling, screen content, signal bars. Marks that stand for detail rather than being detail.

What strokeWidth reaches

The strokeWidth prop lands on the root svg, where children inherit it. A child that carries its own stroke-width beats that inherited value, so the build strips those child attributes. It does so per icon and all-or-nothing.

All-or-nothing is the point. Strip coin-lira’s 2s and leave its 1.8, and at strokeWidth={1} the thinned interior renders thicker than the stroke it was thinning: the optical adjustment inverted. That is a worse bug than an inert prop.

1,788 outline icons respond to the prop. These do not, and the reason matters more than the count:

CountWhatWhy
58Outline icons with an interior tierMixed widths. An optical adjustment as above, like coin-lira or circle-info.
18Outline icons drawn off the baseA uniform width that is not 2. The whole glyph is drawn off the base tier, so stripping would retune it at default props.
4Outline icons with an inheriting childA stroked child with no width of its own. Its widths agree at the default and diverge the moment the prop moves.
2,471Filled componentsFilled art is outline-expanded from a 2px stroke into a fill. There is no stroke left to vary. lucide-react behaves the same way for the same reason.

absoluteStrokeWidth scales strokeWidth by 24 / size, matching lucide-react, and reaches exactly the same icons for exactly the same reasons.

Icons that swap must share an extent

When one icon replaces another in the same slot, a toggle state, an enumeration, a status set sharing a container, the two must agree on their bounding box. A swap across disagreeing extents makes the icon jump in place.

Those groups are recorded in icons-data/_cohorts.json and checked. Membership is behavioural, not lexical: a shared noun is not enough, which is why desk-lamp is recorded as not a desk-office variant rather than left to a name-prefix guess.

A stroked icon’s visual extent is its path bounding box inflated by the stroke width, half per side. Comparing a stroked bounding box against a filled one conflates a rendering fact with a design fact, and it is the single mistake that has produced the most wrong measurements here.

One idea, one icon

Tags are many-to-many and they collide, which is right for finding something and wrong for deciding something. The concepts table is the other half: each concept resolves to exactly one canonical icon, so a set cannot quietly grow two answers to the same question.

Search covers the case where you cannot name the thing. It runs an exact match on slug and alias, then an all-token match, then a fuzzy pass over names, tags, categories, and Lucide aliases. That is why search finds magnifying glass, and why categories exist for browsing what the set already covers.

Adding an icon

An icon is two files: the drawing and the record that describes it. Everything else is generated.

# 1. Draw it. 24x24 viewBox, 2px stroke, stroke="currentColor".
#    Save as packages/blode-icons-react/icons-svg/<slug>.svg
#    A filled companion, when it has one, is <slug>-filled.svg

# 2. Describe it. packages/blode-icons-react/icons-data/<slug>.json
#    { "icon": "<slug>", "category": "...", "tags": [...] }

# 3. Check the data before anything is generated from it.
npm run validate:icons-data --workspace blode-icons-react

# 4. Generate the React components from the SVGs.
npm run build --workspace blode-icons-react

# 5. Copy the set into the docs app and rebuild its search index.
npm run copy-icons --workspace docs

# 6. Record the change for the next release.
npm run changeset

The record is small on purpose:

{
  "icon": "airplane",
  "category": "Things",
  "tags": ["flight", "travel", "plane", "trip", "aviation"]
}

category must be one of the names in _categories.json, and validation fails on anything else, so the taxonomy cannot grow a synonym by accident. Tags are the search surface: write the words someone would type when they cannot remember what the icon is called.

If the new icon swaps with an existing one, add it to its cohort in _cohorts.json. If it answers a concept nothing else answers, add it to _concepts.json. If it takes over a concept an older icon held, change that entry rather than adding a second.

User-facing changes to the package need a changeset before merge. The full source, and the scripts every step above runs, are on GitHub.