Phosphor Icons in Astro: Kill Every Emoji on Your Site in One Session

phosphor-astro migration web

TL;DR

Emoji render inconsistently across platforms, break the manuscript aesthetic, and are technically decorative images without alt text. Phosphor’s Astro integration (phosphor-astro) tree-shakes to ~200B per icon, renders as inline SVG, and respects forced-colors mode. The migration pattern: Phosphor components for anything in a template, Unicode dingbats for anything interpolated in a JS string.

The Problem

bythewei.co uses a medieval manuscript aesthetic — parchment, ink, verdigris, Cinzel serif fonts. Emoji are the visual equivalent of a neon sign in a scriptorium. They also:

  • Render differently on every OS (Apple vs Google vs Windows vs Samsung)
  • Don’t respond to color CSS properties (they’re bitmap, not vector)
  • Break forced-colors mode (Windows High Contrast)
  • Can’t be styled to match your design system

The Pattern

Two distinct replacement strategies depending on where the icon lives:

1. Template icons (nav, headers, buttons) -> Phosphor components

---
import ScrollDuotone from 'phosphor-astro/ScrollDuotone.astro';
---
<span class="icon" aria-hidden="true">
  <ScrollDuotone width="14" height="14" />
</span>

Phosphor renders as inline <svg> — inherits color from parent, responds to CSS, works in forced-colors mode. The duotone weight has a distinctive two-layer look that pairs well with manuscript aesthetics.

2. Inline text icons (JS strings, data attributes) -> Unicode dingbats

// Before: emoji
const glyphMap = {
  clipboard_copy: '\u{1F4CB}',  // clipboard emoji
  bedtime: '\u{1F319}',         // crescent moon emoji
};

// After: Unicode dingbats (render as text glyphs, not colored emoji)
const glyphMap = {
  clipboard_copy: '\u2702',     // scissors (text)
  bedtime: '\u263E',            // last quarter moon (text)
};

Key insight: Unicode characters below U+1F000 generally render as text glyphs, not colored emoji. \u2702 (scissors) renders as a simple black glyph. \u{1F4CB} (clipboard) renders as a colored emoji. Same semantic meaning, different rendering behavior.

Useful Unicode dingbats that don’t render as emoji

SymbolCodeNameGood for
\u2702ScissorsClipboard/copy
\u26A1High voltagePower/charging
\u263ELast quarter moonNight/sleep
\u266BBeamed eighth notesAudio/music
\u00B6PilcrowText/posts
\u2713Check markCompletion
\u2261Triple barLists/books
\u270EPencilWriting/notes
\u2665Heart suitHeart rate
\u25C9FisheyeFocus/target

The Migration

31 files touched across bythewei.co:

  • 2 shared components (GlypharyNav, PalimpsestSubNav) — Phosphor components
  • 2 data components (Concordance filters, Entry timeline) — mixed (Phosphor for buttons, dingbats for inline strings)
  • 19 page files — Phosphor imports for section header icons
  • 1 registry file — HTML entity swap (emoji -> dingbat)
  • 1 OG image generator — dingbat swap

Total bundle impact: negligible. Phosphor tree-shakes aggressively — only the icons you import get bundled, and each is ~200 bytes of inline SVG.

The Gotcha

Not every Phosphor icon name exists. HoleDuotone doesn’t exist — the build will fail silently in dev but hard-fail on npx astro build. Always verify: ls node_modules/phosphor-astro/ | grep -i YourIconName.