SVG icon system design: icon library selection (Lucide, Phosphor, Heroicons), custom icon brief writing, SVG optimization with SVGO, icon tokens (size, color, stroke), accessibility (aria-label, title, role), sprite sheet generation, icon naming conventions, and icon-to-code workflow. From picking an icon set to shipping accessible, performant icons.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Choose based on three criteria: style fit, license, and feature completeness.
| Library | Style | License | Count | Variable weight |
|---|---|---|---|---|
| Lucide | Clean line, 2px stroke | ISC (free) | 1400+ | No |
| Phosphor | Versatile, 6 weights | MIT (free) | 1200+ | Yes (thin/light/regular/bold/fill/duotone) |
| Heroicons | Tailwind-native, clean | MIT (free) | 292 | outline + solid |
| Tabler | Stroke-based, consistent | MIT (free) | 4800+ | No |
| Radix Icons | Minimal, 15×15 grid | MIT (free) | 318 | No |
| Material Symbols | Google, adaptive optical size | Apache 2.0 | 2900+ | Variable font |
Decision guide:
Tailwind project → Heroicons (designed together)
React component library → Lucide (tree-shakeable, consistent)
Need weight variants → Phosphor
Large icon count needed → Tabler
Material Design app → Material Symbols (variable font)
Custom brand icons → Write a brief (see below)
Every icon system needs three token layers:
:root {
--icon-xs: 12px; /* inline text companion */
--icon-sm: 16px; /* button/label icons */
--icon-md: 20px; /* default UI icon */
--icon-lg: 24px; /* prominent action icons */
--icon-xl: 32px; /* feature/hero icons */
--icon-2xl: 48px; /* illustration-scale icons */
}
:root {
--icon-stroke-sm: 1.5px; /* delicate, for large icons */
--icon-stroke-md: 2px; /* default (matches most libraries) */
--icon-stroke-lg: 2.5px; /* bold/accessible contexts */
}
Rule: stroke width must scale with icon size. A 2px stroke on a 12px icon looks heavy; use 1.5px. A 2px stroke on a 48px icon looks thin; use 2.5px.
Icons inherit currentColor by default — do NOT hardcode colors in SVG fill/stroke.
/* Semantic icon colors */
--icon-default: var(--color-neutral-700);
--icon-muted: var(--color-neutral-400);
--icon-brand: var(--color-primary);
--icon-success: var(--color-success);
--icon-warning: var(--color-warning);
--icon-danger: var(--color-error);
--icon-inverse: var(--color-neutral-50); /* on dark backgrounds */
Never ship raw SVG from Figma or icon libraries — run through SVGO first.
svgo.config.js)module.exports = {
plugins: [
'removeDoctype',
'removeXMLProcInst',
'removeComments',
'removeMetadata',
'removeEditorsNSData',
'cleanupAttrs',
'mergeStyles',
'inlineStyles',
'minifyStyles',
'cleanupIds', // remove unnecessary IDs
'removeUselessDefs',
'cleanupNumericValues',
'convertColors', // normalize color values
'removeUnknownsAndDefaults',
'removeNonInheritableGroupAttrs',
'removeUselessStrokeAndFill',
'removeViewBox', // keep false if icons vary in size
'cleanupEnableBackground',
'removeHiddenElems',
'removeEmptyText',
'convertShapeToPath',
'moveElemsAttrsToGroup',
'moveGroupAttrsToElems',
'collapseGroups',
'convertPathData',
'convertEllipseToCircle',
'convertTransform',
'removeEmptyAttrs',
'removeEmptyContainers',
'mergePaths',
'removeUnusedNS',
'sortDefsChildren',
'removeTitle', // keep false if you need title for accessibility
'removeDesc',
],
};
Typical result: 30-60% file size reduction.
Use a sprite sheet for icons used multiple times across a page — reduces DOM nodes.
scripts/build-icons.js)const fs = require('fs');
const path = require('path');
const iconsDir = './src/icons';
const outputFile = './public/icons/sprite.svg';
const icons = fs.readdirSync(iconsDir)
.filter(f => f.endsWith('.svg'))
.map(f => {
const id = path.basename(f, '.svg');
const content = fs.readFileSync(path.join(iconsDir, f), 'utf8');
// Extract inner SVG content, wrap in <symbol>
const inner = content
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>/, '');
return `<symbol id="icon-${id}" viewBox="0 0 24 24">${inner}</symbol>`;
});
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">\n${icons.join('\n')}\n</svg>`;
fs.writeFileSync(outputFile, sprite);
<!-- Inline sprite at top of body (or load via fetch) -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- sprite content -->
</svg>
<!-- Use icon -->
<svg class="icon icon-md" aria-hidden="true" focusable="false">
<use href="/icons/sprite.svg#icon-arrow-right" />
</svg>
interface IconProps {
name: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
label?: string; // accessible label (required for meaningful icons)
className?: string;
}
const sizeMap = { xs: 12, sm: 16, md: 20, lg: 24, xl: 32 };
export function Icon({ name, size = 'md', label, className }: IconProps) {
const px = sizeMap[size];
return (
<svg
width={px}
height={px}
aria-hidden={!label}
aria-label={label}
role={label ? 'img' : undefined}
focusable="false"
className={className}
>
<use href={`/icons/sprite.svg#icon-${name}`} />
</svg>
);
}
// Decorative (no label): aria-hidden="true"
<Icon name="arrow-right" />
// Meaningful (standalone, must have label):
<Icon name="close" label="Close dialog" />
| Scenario | Pattern |
|---|---|
| Icon beside visible text label | aria-hidden="true" on SVG — text already describes it |
| Icon is the only label (button/link) | aria-label on the SVG or <title> inside SVG |
| Standalone icon with complex meaning | <title> + aria-labelledby |
| Decorative (purely visual) | aria-hidden="true", focusable="false" |
<!-- Icon-only button — WRONG (no accessible name) -->
<button><svg>...</svg></button>
<!-- CORRECT option 1: aria-label on button -->
<button aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">...</svg>
</button>
<!-- CORRECT option 2: visually hidden text -->
<button>
<svg aria-hidden="true" focusable="false">...</svg>
<span class="sr-only">Close dialog</span>
</button>
Consistent naming prevents icon-close vs icon-x vs icon-dismiss chaos.
Format: kebab-case, all lowercase
Prefix: none (the component adds "icon-")
Category: noun-first, then modifier
Examples:
arrow-right (not right-arrow)
arrow-up
arrow-down
chevron-right (smaller, subtle arrow)
caret-down (for dropdowns)
check (not checkmark, tick, done)
check-circle (filled success state)
x (not close, dismiss, cancel)
x-circle
alert-triangle (not warning, caution)
alert-circle
info-circle
plus
minus
search
user
users
settings
trash
edit
eye
eye-off
WRONG: pencil, garbage-bin, magnifying-glass
RIGHT: edit, trash, search
Semantic names survive icon style changes (pencil → pen → edit icon).
When custom icons are needed, write a brief instead of designing them ad hoc.
## Custom Icon Brief — [Product Name]
### Icon style
- Grid: 24×24px (use 20×20 for compact variant)
- Stroke width: 2px, round cap and join
- Style: [Line only | Filled | Duotone (line + fill at 20% opacity)]
- Corner radius: [0 = sharp | 2px = slightly rounded | 4px = friendly]
### Visual language
- [2-3 sentences on the aesthetic — e.g., "Clean and precise, technical feel.
No decorative elements. Minimal path count."]
### Reference library
- Base style on: [Lucide | Heroicons | Phosphor Regular]
- Diverge by: [e.g., slightly rounder corners, 1.5px stroke instead of 2px]
### Icons needed
| Name | Purpose | Notes |
|------|---------|-------|
| api-key | Represents an API key credential | Key shape, maybe with circuit motif |
| webhook | Webhook event trigger | Chain/hook motif |
### Prohibited patterns
- No gradient fills
- No drop shadows
- No more than 3 distinct path shapes per icon
### Deliverables
- SVG files, 24×24 viewBox
- Optimized via SVGO
- Named: kebab-case (e.g., api-key.svg)
currentColor — no hardcoded fill/stroke colorsaria-hidden="true" and focusable="false"aria-label or <title> present