SVG Icons skill for Claude Code
One grid, currentColor, and names screen readers say. Claude replaces emoji, icon fonts and mixed icon sets with one consistent family on a defined grid, runs SVGO with settings that keep viewBox and convert colors to currentColor, and ships icons as tree-shaken components or a sprite. A bundled audit script flags hard-coded fills, missing viewBox, ID collisions and mixed stroke widths, and every icon gets the right ARIA treatment.
- name
svg-icons - size180 lines · ≈1,800 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds consistent, accessible SVG icon systems — one grid and stroke weight, SVGO optimization, currentColor theming, delivery as inline components or a sprite with use, correct ARIA for decorative versus meaningful icons and icon-only buttons, and a stdlib audit script that flags hard-coded colors, missing viewBox, and ID collisions. Use when adding or cleaning up icons, replacing an icon font or emoji icons, building an icon component, or exporting icons from Figma. Try asking
- Replace the emoji icons in our feature list with a proper SVG icon set
- Our icons are invisible in dark mode. Audit the SVGs and fix them
- Set up an icon component system with SVGR and accessible icon buttons
Use it when
- Adding or standardizing icons in a product
- Migrating off an icon font or emoji icons
- Exported Figma icons look inconsistent or break dark mode
- Icon-only buttons need accessible names
Skip it when
- You are designing a logo or brand mark, which needs its own colors and is not an icon system
- You need a full accessibility audit beyond icons, use accessibility-audit instead
How to check the result
The bundled svg_audit.py exits 0, icons follow text color in both themes, and a screen reader skips decorative icons while announcing icon button labels.
The common mistake it prevents
Running SVGO with currentColor conversion on multicolor logos and illustrations, which flattens them into a single color.
An icon system is done when every icon shares one grid, one stroke weight, and one
corner style; icons inherit color from text via currentColor; size is controlled by CSS;
decorative icons are hidden from assistive tech while meaningful ones have a name; and
the audit script reports zero errors.
Ground rules
- One icon family per product. Mixing Heroicons, Font Awesome, and emoji reads as unfinished. If a needed glyph is missing, draw it on the same grid.
- Emoji are not icons: they render differently per OS, cannot be colored, and screen readers announce their names (“rocket”).
- No icon fonts for new work: they fail when fonts are blocked, render blurry, and are announced as stray characters.
- Icons support labels; they rarely replace them. Icon-only buttons are acceptable for universal actions (close, search, menu, more) and need an accessible name and a tooltip.
Pick a family (or define your own grid)
| Family | Grid / style | Notes |
|---|---|---|
| Lucide | 24, 2px stroke, round caps | Large set, React/Vue/Svelte packages, stroke width adjustable |
| Heroicons | 24 outline (1.5px) / 24 solid / 20 mini / 16 micro | Tailwind ecosystem |
| Phosphor | 256 viewBox, six weights | Weight variety in one family |
| Tabler | 24, 2px stroke | Very large set |
| Material Symbols | variable font or SVG; fill/weight/grade axes | Use the SVG exports, not the font, for small sets |
Custom grid spec (write it down for designers):
Canvas 24×24, live area 20×20 (2px padding), stroke 1.5px or 2px, round caps and joins,
corner radius 2px on 24 grid, minimum gap between strokes 2px, optical sizes at 16 and 20
redrawn rather than scaled when detail is lost.
Workflow
- [ ] 1. Inventory icons in use (and emoji/icon-font usages to replace)
- [ ] 2. Choose family or grid spec
- [ ] 3. Export/collect SVGs, run SVGO
- [ ] 4. Audit with scripts/svg_audit.py; fix errors
- [ ] 5. Choose delivery (component, sprite, or img)
- [ ] 6. Implement accessible usage patterns
- [ ] 7. Verify sizing, color, and screen reader output
1. Inventory
grep -rnoE '<i class="(fa|icon|material-icons)[^"]*"' src | wc -l # icon fonts
grep -rnoP '[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]' src | head # emoji (GNU grep -P)
find . -name '*.svg' -not -path '*/node_modules/*' | wc -l
3. Optimize with SVGO
// svgo.config.mjs (SVGO v4)
export default {
multipass: true,
plugins: [
{ name: "preset-default", params: { overrides: { convertColors: { currentColor: true } } } },
"removeDimensions", // drop width/height, keep viewBox
{ name: "prefixIds", params: { prefixClassNames: false } },
],
};
npx svgo --config svgo.config.mjs -f icons/raw -o icons/optimized
- SVGO v4 no longer removes
viewBoxor<title>by default. On v3, addremoveViewBox: falseto the preset overrides or icons stop scaling. convertColors.currentColor: truerewrites every color tocurrentColor: correct for monochrome icons, wrong for multicolor illustrations or brand logos; run those with a separate config.
4. Audit
python3 scripts/svg_audit.py icons/optimized --size 24 --max-bytes 2048
Reports missing viewBox, off-grid viewBoxes, hard-coded width/height, hard-coded
fill/stroke colors, <style>/<script>/raster <image>, IDs that collide when inlined,
mixed stroke widths across the set, and oversized files. Exit code 1 on errors.
5. Choose delivery
| Situation | Delivery | Why |
|---|---|---|
| React/Vue/Svelte app, < ~200 icons used | Inline SVG components (tree-shaken package or SVGR output) | Styling via CSS, no extra requests, only used icons ship |
| Server-rendered or static site, many repeated icons | External sprite + <use> | One cached file, tiny markup |
| Large, multicolor, or decorative illustrations | <img src="x.svg" alt=""> | Cached, no DOM cost; cannot inherit color |
| Icon font | Do not use for new work | — |
Generate React components with SVGR:
npx @svgr/cli --icon --typescript --out-dir src/icons -- icons/optimized
Sprite usage (same-origin only; <use> does not load cross-origin files):
<svg class="icon" aria-hidden="true" focusable="false"><use href="/icons.svg#search"></use></svg>
Build the sprite by wrapping each optimized icon’s contents in
<symbol id="name" viewBox="…"> inside one <svg xmlns="http://www.w3.org/2000/svg">;
a small script or svg-sprite/vite-plugin-svg-icons style tooling does this.
6. Accessible usage
.icon { width: 1.25em; height: 1.25em; flex: none; vertical-align: -0.2em; stroke-width: 2; }
.icon-sm { width: 1rem; height: 1rem; }
| Case | Markup |
|---|---|
| Decorative, next to visible text | <svg aria-hidden="true" focusable="false">…</svg> |
| Icon-only button | <button type="button" aria-label="Close dialog"><svg aria-hidden="true" focusable="false">…</svg></button> |
| Standalone meaningful icon (status) | <svg role="img" aria-label="Error">…</svg> or adjacent visually hidden text |
| Icon link | Name on the <a> (aria-label or hidden text), icon hidden |
- Put the accessible name on the interactive element, not inside the SVG, so it works consistently across screen readers.
focusable="false"prevents old Edge/IE from tabbing into SVGs; harmless elsewhere.- Touch target is the button, not the glyph: 16–20px icon inside a 40–44px button.
- Contrast: icons that convey meaning need 3:1 against the background (WCAG 1.4.11).
React component pattern
type IconProps = React.SVGProps<SVGSVGElement> & { label?: string };
export function SearchIcon({ label, ...props }: IconProps) {
const a11y = label ? { role: "img", "aria-label": label } : { "aria-hidden": true, focusable: false };
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" width="1em" height="1em" {...a11y} {...props}>
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
);
}
Gotchas
- Importing a whole icon library’s index without tree-shaking ships thousands of icons;
check the bundle (see
bundle-analysis) and import per icon. - Duplicate IDs (
clip0, gradients from Figma exports) break icons when two are inlined on one page;prefixIdsor removing unused defs fixes it. - Scaling a 24px stroke icon down to 12px makes a 1px stroke that blurs; use a 16px optical size.
fill="none"on the root plusfill="#000"on a child is a hard-coded color the audit catches; dark mode makes it invisible.- Figma exports include
width/height, masks, and clipping paths; flatten outlines and remove masks before export.
Verification
python3 scripts/svg_audit.py <icons-dir>exits 0.- Place icons next to text at 14, 16, and 20px: optical weight looks equal, baseline alignment consistent.
- Toggle dark mode: every icon follows text color.
- Screen reader (VoiceOver rotor or NVDA elements list): decorative icons absent, icon buttons announce their label.
- Bundle size: only used icons appear in the production build.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/svg_audit.py
#!/usr/bin/env python3
"""Audit a folder of SVG icons for consistency problems. Standard library only.
Usage:
python3 svg_audit.py <dir-or-file> [...] [--size 24] [--max-bytes 2048]
Checks per file:
- root <svg> has a viewBox, and it matches --size (e.g. "0 0 24 24")
- hard-coded width/height attributes on the root (prevents CSS sizing)
- hard-coded colors in fill/stroke (anything other than none/currentColor/inherit)
- <style>, <script>, embedded raster <image>, and inline event handlers
- id attributes (collide when icons are inlined or combined into a sprite)
- mixed stroke-width values across the set
- file size over --max-bytes
Exit code 1 if any error-level issue is found.
"""
import argparse
import re
import sys
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
SVG_NS = "{http://www.w3.org/2000/svg}"
OK_PAINT = {"none", "currentcolor", "inherit", "transparent"}
def local(tag):
return tag.split("}", 1)[-1]
def paint_values(el):
vals = []
for attr in ("fill", "stroke"):
v = el.get(attr)
if v is not None:
vals.append((attr, v.strip()))
style = el.get("style") or ""
for m in re.finditer(r"(fill|stroke)\s*:\s*([^;]+)", style):
vals.append((m.group(1), m.group(2).strip()))
return vals
def audit(path, size, max_bytes, stroke_widths):
errors, warnings = [], []
raw = path.read_bytes()
if len(raw) > max_bytes:
warnings.append(f"{len(raw)} bytes (> {max_bytes}); run SVGO")
try:
root = ET.fromstring(raw)
except ET.ParseError as e:
return [f"not valid XML: {e}"], warnings
if local(root.tag) != "svg":
return ["root element is not <svg>"], warnings
vb = root.get("viewBox")
if not vb:
errors.append("missing viewBox (icon cannot scale)")
else:
parts = vb.replace(",", " ").split()
if size and parts[2:] != [str(size), str(size)]:
warnings.append(f'viewBox "{vb}" differs from grid {size}x{size}')
for attr in ("width", "height"):
if root.get(attr) is not None:
warnings.append(f'root has {attr}="{root.get(attr)}" (size with CSS instead)')
ids = []
for el in root.iter():
name = local(el.tag)
if name == "script":
errors.append("contains <script>")
if name == "style":
warnings.append("contains <style> (styles leak globally when inlined)")
if name == "image":
errors.append("embeds a raster <image>")
for k in el.attrib:
if k.lower().startswith("on"):
errors.append(f"inline event handler {k}")
if el.get("id"):
ids.append(el.get("id"))
for attr, v in paint_values(el):
if v.lower() not in OK_PAINT and not v.startswith("url("):
errors.append(f'hard-coded {attr}="{v}" on <{name}> (use currentColor)')
sw = el.get("stroke-width")
if sw:
stroke_widths[sw] += 1
if ids:
warnings.append(f"ids present ({', '.join(ids[:3])}{'...' if len(ids) > 3 else ''}); prefix or remove before inlining")
return sorted(set(errors)), sorted(set(warnings))
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("paths", nargs="+")
p.add_argument("--size", type=int, default=24, help="expected square grid; 0 to skip")
p.add_argument("--max-bytes", type=int, default=2048)
a = p.parse_args()
files = []
for raw in a.paths:
pth = Path(raw)
files += sorted(pth.rglob("*.svg")) if pth.is_dir() else [pth]
if not files:
print("no .svg files found", file=sys.stderr)
return 2
stroke_widths = Counter()
n_err = n_warn = 0
for f in files:
errs, warns = audit(f, a.size, a.max_bytes, stroke_widths)
if errs or warns:
print(f"\n{f}")
for e in errs:
print(f" ERROR {e}")
for w in warns:
print(f" warn {w}")
n_err += len(errs)
n_warn += len(warns)
if len(stroke_widths) > 1:
print(f"\nmixed stroke-width values across set: {dict(stroke_widths)}")
n_warn += 1
print(f"\n{len(files)} file(s), {n_err} error(s), {n_warn} warning(s)")
return 1 if n_err else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.