No More Emojis: Building an Animated SVG Icon System for a Kids App


Our kids puzzle app had emojis everywhere. 🏠 for Home. 👤 for Profile. 🎨 for Print Activities. 🎲 for Next Puzzle. They were fast to add and looked fine at a glance — but they had real problems: rendering inconsistencies across platforms, no animation, and zero control over color or size.

We replaced every single one with animated inline SVG components. Here’s how we built the system and why it was worth it.

The Pattern

Every icon lives in a client/src/icons/<Area>Icons.tsx file. Each file exports:

  1. A use<Area>IconStyles() hook that injects CSS keyframe animations once via document.head
  2. React SVG components — 22×22px, viewBox="0 0 24 24", aria-hidden="true"

Animations are triggered by a parent class, not the icon itself. This means the icon is passive — the container decides when to animate:

// CSS (injected once via hook)
.fab-icon-item:hover .home-body {
  animation: home-bob 0.5s ease-in-out forwards;
}

// Icon component
export function HomeIcon() {
  return (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <g className="home-body">
        <polygon points="12,3 22,11 2,11" fill="#6366f1" />
        <rect x="4" y="11" width="16" height="10" rx="0.5" fill="#6366f1" />
        <rect x="9.5" y="15" width="5" height="6" rx="0.5" fill="#c7d2fe" />
      </g>
    </svg>
  );
}

// Usage — the Link gets the trigger class, not the icon
<Link className="flex items-center gap-3 fab-icon-item">
  <HomeIcon />
  Home
</Link>

The pattern keeps icons portable — the same HomeIcon can animate in a button, a card, or a gallery, as long as the parent carries the trigger class.

Style Injection Once

Each use<Area>IconStyles() hook guards against duplicate injection with a STYLE_ID check:

const STYLE_ID = "fab-icon-styles";

export function useFABIconStyles(): void {
  useEffect(() => {
    if (document.getElementById(STYLE_ID)) return;
    const el = document.createElement("style");
    el.id = STYLE_ID;
    el.textContent = CSS;
    document.head.appendChild(el);
  }, []);
}

Call it at the top of any component that renders FAB icons. If it’s already been injected, it’s a no-op.

currentColor for Context-Aware Icons

Some icons appear on buttons with different text colors. The completion overlay has a “Print Activities” button (dark text) and a “Next Puzzle” button (white text on a gradient). Using fill="currentColor" means the icon automatically inherits whatever text color the button has:

export function PrintIcon() {
  return (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <g className="print-brush" style={{ transformOrigin: "12px 12px" }}>
        <rect x="10.5" y="2" width="3" height="12" rx="1.5"
          fill="currentColor"           {/* ← inherits button text color */}
          transform="rotate(-10 12 12)"
        />
        <ellipse cx="11" cy="16" rx="3" ry="2.2"
          fill="currentColor" opacity="0.7"
          transform="rotate(-10 12 12)"
        />
      </g>
    </svg>
  );
}

What We Replaced

Across the app we converted 20 emoji icons to SVG:

Print Activities — 6 activity icons for the print modal (Color It, Maze, Write It, Cut-Out Puzzle, Connect the Dots, Draw It!). Each icon is unique and animates on hover: a crayon swings, maze walls flash, a pencil bounces, scissors snip, dots pulse, a brush strokes.

FAB navigation — 3 icons for the floating menu. Home bobs upward, Profile pops to scale, Stats bars grow from the bottom with a stagger delay.

Profile page — 3 stat card icons (Stars, Puzzles, Stations) and 9 achievement badge icons. The star pulses, the puzzle piece rocks, the map bounces. Achievement icons all use a shared badge-pop scale animation.

Completion overlay — 2 icons for the post-puzzle screen. The paintbrush swings when hovering Print Activities; the die rotates when hovering Next Puzzle. We also removed two non-functional sound buttons from this screen while we were here.

With 20 icons across 4 areas it became hard to review changes without navigating through the actual app flows. So we built a dev-only gallery at /icons:

// Only available in development
{import.meta.env.DEV && (
  <>
    <Route path="/status" element={<Suspense fallback={null}><Status /></Suspense>} />
    <Route path="/icons" element={<Suspense fallback={null}><IconGallery /></Suspense>} />
  </>
)}

The gallery calls all four style hooks and renders every icon in a grid with hover-to-preview:

function IconCard({ label, bg, Icon }: IconEntry) {
  return (
    // All 4 trigger classes so any icon section animates correctly
    <div className="activity-icon-pill profile-icon-card completion-icon-btn fab-icon-item
                    flex flex-col items-center gap-2 p-4 rounded-2xl border-2 ...">
      <div className="w-12 h-12 rounded-xl flex items-center justify-center"
           style={{ background: bg }}>
        <Icon />
      </div>
      <span className="text-xs font-semibold text-gray-600">{label}</span>
    </div>
  );
}

Opening /icons in dev mode lets you hover every icon to preview its animation without hunting through app flows:

  • Print Activities (6 icons)
  • FAB (3 icons)
  • Profile Stats (3 icons)
  • Profile Achievements (9 icons)
  • Completion (2 icons)

The Convention

We added a project rule to CLAUDE.md so future agents and developers know what to do when adding any new icon:

  1. Create it as a React SVG component in client/src/icons/<AreaName>Icons.tsx
  2. Add CSS animations via the use<Area>IconStyles() hook pattern
  3. Trigger animations via a parent CSS class
  4. Add aria-hidden="true" to the <svg> (decorative icons)
  5. Register in client/src/pages/IconGallery.tsx

The reference implementation is ActivityIcons.tsx — six icons, four keyframes, one hook, one file.

Why It Was Worth It

The immediate wins:

  • Consistent rendering — no more emoji font differences between Android and iOS
  • Color control — icons use exact brand colors, not whatever the OS emoji palette provides
  • Animation — micro-interactions that respond to hover and tap, adding life to static UI
  • Accessibility — every icon has aria-hidden="true" since they’re decorative; the surrounding button or label carries the accessible name
  • Maintainability — the gallery makes it easy to audit and update icons without touching app flows

The less obvious win: having a pattern and a gallery means the next icon is fast. The hard work of establishing the system is done — adding icon #21 is just copying the pattern and dropping it in the gallery.