Phosphor Icons in Astro: Kill Every Emoji on Your Site in One Session
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 respectsforced-colorsmode. 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
colorCSS properties (they’re bitmap, not vector) - Break
forced-colorsmode (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
| Symbol | Code | Name | Good for |
|---|---|---|---|
| ✂ | \u2702 | Scissors | Clipboard/copy |
| ⚡ | \u26A1 | High voltage | Power/charging |
| ☾ | \u263E | Last quarter moon | Night/sleep |
| ♫ | \u266B | Beamed eighth notes | Audio/music |
| ¶ | \u00B6 | Pilcrow | Text/posts |
| ✓ | \u2713 | Check mark | Completion |
| ≡ | \u2261 | Triple bar | Lists/books |
| ✎ | \u270E | Pencil | Writing/notes |
| ♥ | \u2665 | Heart suit | Heart rate |
| ◉ | \u25C9 | Fisheye | Focus/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.