Design & UX No. 362

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.

  • namesvg-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.

~/.claude/skills/svg-icons/SKILL.md

--- name: svg-icons description: Builds consistent, accessible SVG icon systems — one grid and stroke weight, SVGO optimization, currentColor theming, delivery as inline… license: MIT ---

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)

FamilyGrid / styleNotes
Lucide24, 2px stroke, round capsLarge set, React/Vue/Svelte packages, stroke width adjustable
Heroicons24 outline (1.5px) / 24 solid / 20 mini / 16 microTailwind ecosystem
Phosphor256 viewBox, six weightsWeight variety in one family
Tabler24, 2px strokeVery large set
Material Symbolsvariable font or SVG; fill/weight/grade axesUse 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 viewBox or <title> by default. On v3, add removeViewBox: false to the preset overrides or icons stop scaling.
  • convertColors.currentColor: true rewrites every color to currentColor: 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

SituationDeliveryWhy
React/Vue/Svelte app, < ~200 icons usedInline 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 iconsExternal 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 fontDo 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; }
CaseMarkup
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 linkName 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; prefixIds or 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 plus fill="#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 128 lines · 4.3 KB
#!/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.

esc