Icon Systems skill for Codex

One icon family, crisp, themed and labelled. Codex inventories every icon library, inline SVG and icon font in the repo, chooses one family on a 24 px grid, and delivers it as tree-shaken components or a same-origin symbol sprite. Custom icons go through SVGO with viewBox kept and colors switched to currentColor, semantic aliases live in one registry, and icon-only buttons get accessible names.

  • Design & UX
  • $icon-systems
  • 203 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- icon-systems

Installs to ~/.agents/skills/icon-systems/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $icon-systems, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds SVG icon systems - choosing one library or a custom set, a 24 px grid with consistent stroke and optical sizing, currentColor theming, delivery as tree-shaken components, an SVG symbol sprite or CSS masks, SVGO optimization, naming and semantic aliases, and accessible markup for decorative icons, meaningful icons and icon-only buttons. Use when adding or cleaning up icons, mixing icon libraries, building an SVG sprite, fixing blurry, misaligned or wrongly sized icons, or making icon buttons accessible.

Use it when

  • Adding icons to a product or design system
  • Several icon libraries or an icon font are mixed in one UI
  • Building or optimizing an SVG sprite
  • Icon-only buttons lack accessible names

Use something else when

How to check the result

Only one icon family remains in the dependency tree, axe reports no unnamed buttons, and icons render centered and crisp beside 14 to 20 px text in both themes.

The mistake it prevents

Running SVGO with a config that strips viewBox, which leaves icons that cannot scale with CSS.

~/.agents/skills/icon-systems/SKILL.md

--- name: icon-systems description: Builds SVG icon systems - choosing one library or a custom set, a 24 px grid with consistent stroke and optical sizing, currentColor theming,… license: MIT ---

An icon system is right when every icon comes from one visual family (same grid, stroke, corner and fill logic), icons inherit color and size from text, the bundle only ships icons actually used, and every icon is either explicitly decorative or has an accessible name. Three libraries mixed on one screen is the most visible sign of a product without a system.

Before changing anything

  • Read AGENTS.md and the package manifest. Inventory icon sources:
rg -o "from ['\"](lucide-react|@heroicons/[^'\"]+|@phosphor-icons/[^'\"]+|react-icons/[^'\"]+|@tabler/icons-react|@mui/icons-material[^'\"]*)['\"]" -N src | sort | uniq -c
rg -l --glob '*.{tsx,jsx,vue,svelte,html}' '<svg' src | wc -l      # inline one-off SVGs
rg -l 'font-awesome|material-icons|icomoon' src package.json        # icon fonts
  • Count distinct icons in use; most products need 60–150.

Choose the source

SituationDefault
General product UI, React/Vue/SvelteOne open library: Lucide (stroke), Phosphor (multiple weights), Heroicons (Tailwind teams), Tabler (large set), Material Symbols (Material apps)
Strong brand, marketing-heavyCustom set drawn on the same grid, or a library extended with custom icons drawn to its rules
Server-rendered HTML, no frameworkSVG sprite built from the chosen library’s raw SVGs
Legacy icon fontMigrate to SVG; fonts render as missing glyphs, anti-alias poorly and are hard to label

Pick one family. When a needed icon is missing, draw it to the family’s rules (grid, stroke, caps, corner radius) rather than borrowing from another library.

Grid and sizing

  • Draw on a 24 x 24 viewBox with about 2 px padding (20 px live area). Keep stroke width constant across the set (1.5 or 2 at 24 px), round or square caps consistently.
  • Render sizes: 16, 20, 24 px (and 32+ for empty states). At 16 px, a 2 px stroke scaled to 1.33 px can blur; use the library’s optical variant or absoluteStrokeWidth style options (Lucide supports it) to keep strokes crisp.
  • Size icons relative to text: 1em square next to text, or 1.25x the font size for emphasis. Icon and label gap: 0.375–0.5em.
  • Align to text with flex (display:inline-flex; align-items:center; gap) rather than vertical-align hacks.
  • Touch targets are the button, not the glyph: a 20 px icon in a 40–44 px button.
.icon {
  width: 1em; height: 1em;
  flex: none;
  stroke: currentColor;      /* stroke icons */
  fill: none;
}
.icon--filled { fill: currentColor; stroke: none; }
.icon-label { display: inline-flex; align-items: center; gap: .4em; }

Delivery

MethodWhenNotes
Component per icon (import { Check } from "lucide-react")React/Vue/Svelte appsTree-shaken; never import the whole set or use import *
SVG <symbol> sprite + <use>Server-rendered, many repeated icons, non-JS pagesOne cached file; external sprite must be same-origin
Inline SVG in markupA handful of icons, or icons that animateDuplicates markup per use
CSS mask-imagePurely decorative icons in CSS (list bullets, pseudo-elements)Colored with background-color: currentColor; cannot carry an accessible name

Sprite usage:

<svg class="icon" aria-hidden="true"><use href="/icons/sprite.svg#check"></use></svg>

Build the sprite from a folder of optimized SVGs (Node 20+, no dependencies):

// scripts/build-sprite.mjs  ->  node scripts/build-sprite.mjs icons/optimized public/icons/sprite.svg
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";

const [src, out] = process.argv.slice(2);
const symbols = readdirSync(src).filter((f) => f.endsWith(".svg")).sort().map((f) => {
  const svg = readFileSync(join(src, f), "utf8");
  const root = svg.match(/<svg\b[^>]*>/)?.[0] ?? "";
  const viewBox = root.match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 24 24";
  const attrs = ["fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin"]
    .map((name) => [name, root.match(new RegExp(`\\s${name}="([^"]+)"`))?.[1]])
    .filter(([, value]) => value)
    .map(([name, value]) => `${name}="${value}"`)
    .join(" ");
  const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
  return `<symbol id="${basename(f, ".svg")}" viewBox="${viewBox}" ${attrs}>${inner}</symbol>`;
});
writeFileSync(out, `<svg xmlns="http://www.w3.org/2000/svg">${symbols.join("")}</svg>\n`);
console.log(`wrote ${symbols.length} symbols to ${out}`);

The script keeps root-level stroke/fill attributes on each <symbol> because many libraries (Lucide, Tabler) set them on the <svg> element, not on the paths. If you inline the sprite into the HTML instead of referencing the file, hide it with width="0" height="0" style="position:absolute" rather than display:none, which breaks gradients and masks inside symbols in some browsers.

Optimization with SVGO

npx svgo -f icons/raw -o icons/optimized --config svgo.config.mjs
// svgo.config.mjs
export default {
  multipass: true,
  plugins: [
    "preset-default",
    "removeDimensions",                       // drop width/height, keep viewBox
    { name: "removeAttrs", params: { attrs: "(class|data-.*)" } },
    { name: "prefixIds", params: { prefixClassNames: false } },
  ],
};

SVGO 4 no longer strips viewBox by default; on SVGO 3, add { name: "preset-default", params: { overrides: { removeViewBox: false } } }, because an icon without a viewBox cannot scale. Replace hard-coded colors (fill="#000") with currentColor during cleanup. prefixIds prevents gradient and mask id collisions when many inline SVGs share a page. npx svgo needs network once to fetch the package.

Naming

  • File and component names describe the glyph: trash, arrow-right, circle-check.
  • Semantic aliases map meaning to glyph in one registry, so a redesign swaps the glyph in one place:
export const icons = {
  delete: Trash2,
  success: CircleCheck,
  external: ArrowUpRight,
  settings: Settings,
} as const;

Accessibility

UseMarkup
Decorative or next to a visible labelaria-hidden="true" on the <svg>
Icon-only button or linkName on the control: <button aria-label="Delete invoice">, svg aria-hidden="true"
Standalone meaningful icon (status in a table cell)<svg role="img" aria-label="Overdue"> or visually hidden text next to it
Icon conveys state with colorAlso change the glyph or add text; color alone fails
  • Add a tooltip for icon-only buttons with uncommon meaning, but the tooltip does not replace aria-label.
  • Icons used as UI boundaries or essential graphics need 3:1 contrast against the background.
  • Do not put <title> inside sprite symbols expecting it to name the icon at each use; name the control instead.

Workflow

- [ ] 1. Inventory libraries, inline SVGs and icon fonts
- [ ] 2. Choose one family and delivery method
- [ ] 3. Map every existing icon to the family (table: old -> new)
- [ ] 4. Optimize custom SVGs with SVGO; convert colors to currentColor
- [ ] 5. Build the component registry or sprite
- [ ] 6. Replace usages; fix accessible names on icon-only controls
- [ ] 7. Remove old icon packages and fonts

Verification

  • Inventory again: one icon package (plus custom set) remains; icon fonts removed.
  • Bundle analyzer or build output: icon code limited to icons used.
  • Screenshot a page with icons next to 14, 16 and 20 px text: centered, same visual weight, crisp at 1x and 2x.
  • axe or Lighthouse: no “button has no accessible name” findings.
  • Toggle dark mode: icons follow text color.

Gotchas

  • Icons with width="24" height="24" attributes ignore CSS 1em sizing unless CSS overrides them; strip dimensions or set both properties in CSS.
  • Filled and outlined icons mixed in one toolbar look like two products.
  • Cross-origin sprites (CDN on another domain) fail silently in <use>; serve from the same origin or inline the sprite.
  • react-icons makes mixing families trivially easy; restrict it with a lint rule.

Output contract

**Family:** <library or custom>, stroke <w> on 24 grid, sizes 16/20/24
**Delivery:** <components | sprite at path | masks>
**Migrated:** <n> icons; removed packages <list>
**Aliases:** <path to registry>
**Accessibility:** <n> icon-only controls named
**Bundle:** icon code <before> -> <after> KB

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Icon Systems"
  short_description: "One icon family, crisp, themed and labelled"
  default_prompt: "$icon-systems We use three icon libraries, consolidate everything onto Lucide"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc