Colour Font Implementation Guide
A thorough reference for designers and developers using Hue Type's colour fonts — installation, CSS font-palette, SBIX vs COLRv1, browser support, and code examples.
1. What Hue Type produces
Every build job outputs three files:
| File | Format | Use for |
|---|---|---|
yourfont.woff2 | COLRv1 WOFF2 | Websites (Chrome, Firefox, Edge) |
yourfont.ttf | COLRv1 TTF | Design tools that need TTF; Chromium apps |
yourfont-safari.ttf | COLRv1 + SBIX TTF | Safari, iOS, macOS Figma, native apps |
WOFF2 is the right choice for almost all web use — compressed (~40% smaller than TTF) and supported in all modern Chromium/Gecko browsers.
The Safari TTF contains both COLRv1 colour tables and an SBIX table with PNG bitmaps at 20 px, 40 px, 80 px, and 160 px so icons render correctly on every Apple device.
2. Preparing SVGs for a colour font
How the 3-slot palette model works, and how to structure your source SVGs so they recolour predictably.
The 3-slot CPAL model
A COLR / CPAL colour font stores each icon as a stack of vector shapes. Each shape is assigned a "slot" in a tiny palette table that ships with the font. Hue Type uses 3 slots per glyph — that's enough for most icons and keeps the file tiny.
| Slot | Z-order | Typical role |
|---|---|---|
| 0 | Bottom (rendered first) | Background / fill plate |
| 1 | Middle | Mid layer / accent |
| 2 | Top (rendered last) | Foreground detail (lines, dots, glyph faces) |
font-palette overrides the colour of each slot at runtime — the structure stays fixed.How nanoemoji maps your SVG to slots
When you upload an SVG to Hue Type, the build pipeline walks every path and groups shapes by their fill colour. Each unique fill colour becomes one CPAL slot:
- Slot 0 ← the colour of the first shape in your SVG source
- Slot 1 ← the colour of the next unique shape
- Slot 2 ← the colour of the next unique shape
The actual hex values you pick don't matter for CSS — they're placeholders. CSS font-palette overrides replace them at runtime. What matters is that you use exactly three distinct colours, and that the SVG source order matches the z-order you want.
Example: a 3-layer "Close" icon
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<!-- Slot 0 — outer circle (background) -->
<circle cx="12" cy="12" r="11" fill="#b7b7b7"/>
<!-- Slot 1 — inner ring (mid layer) -->
<circle cx="12" cy="12" r="9" fill="#d9d9d9"/>
<!-- Slot 2 — X mark (foreground) -->
<path d="M8,8 L16,16 M16,8 L8,16"
stroke="#000000" stroke-width="2.5" fill="none"/>
</svg>
Three shapes, three fills, three slots. The font built from this SVG can later be recoloured with any palette — for example a dark/inverted scheme:
@font-palette-values --inverted {
font-family: "MyIcons";
override-colors:
0 #2a2a2a, /* slot 0: was #b7b7b7 → now near-black */
1 #888888, /* slot 1: was #d9d9d9 → now mid grey */
2 #ffffff; /* slot 2: was #000000 → now white */
}
The icon's structure stays the same — only the colours change. Ship one SVG, recolour it for dark mode, hover states, brand swaps, etc., without rebuilding the font.
Three flavours: duo-tone, tri-tone, illustration
Hue Type lets you build fonts at three complexity levels depending on how many slots each glyph uses. Pick one consistently per project — mixing flavours in the same font is supported but harder to recolour predictably.
Background plate + foreground accent. Best for filled icons with a clear inner mark — check, plus, close, edit.
↓ duo-sample.svg
Background + mid layer + foreground. A three-layer stack — ideal for icons with structural depth like cards, stacks, badges.
↓ tri-sample.svg
Many-layered, multi-tone icons. Every unique fill in the source becomes a new CPAL slot — use this for richer, illustrative glyphs.
↓ illustration-sample.svg
Tip: open each sample SVG in a text editor (or Figma) to see exactly how the layers are structured. The fill values are placeholders — they map to slot indices based on source order, and CSS can remap them at runtime.
Strokes vs filled paths
nanoemoji rasterises every shape as a filled path. Strokes (stroke-width, stroke) are technically supported but mapping them to slots is unpredictable — convert all strokes to outlined paths before exporting.
stroke="..." + stroke-width. Ambiguous slot assignment, stroke width changes during font rasterisation.fill="...", no strokes. Predictable, scales cleanly, slot mapping is deterministic.Gradients & transparency — avoid both
How layers are read from your SVG source
nanoemoji reads paths in document order — the order they appear in the SVG file's XML. It assigns slot indices to unique fill colours as they first appear. So this:
<svg viewBox="0 0 24 24">
<!-- First unique fill = slot 0 -->
<circle cx="12" cy="12" r="11" fill="#b7b7b7"/>
<!-- Second unique fill = slot 1 -->
<circle cx="12" cy="12" r="9" fill="#d9d9d9"/>
<!-- Third unique fill = slot 2 -->
<path d="M8,8 L16,16 M16,8 L8,16" fill="#000000"/>
</svg>
…produces a glyph with three slots in that exact order. If you reorder the elements in the SVG, the slot assignment changes. Always check the source order matches the z-order you want.
In Figma: layer panel bottom-to-top corresponds to SVG top-to-bottom (Figma's bottom layer is the SVG's first element). So your Figma layer stack should look like:
- Top of Figma layer panel → top in z-order → slot 2 → exported last
- Middle of Figma layer panel → mid in z-order → slot 1
- Bottom of Figma layer panel → back in z-order → slot 0 → exported first
Design rules for predictable results
- Exactly 3 distinct fill colours. A 4th colour will either be merged into the closest match or rejected.
- Use placeholder values that are easy to spot in source. Recommended:
#000000/#7c7c7c/#d9d9d9, or pure primary R/G/B. Avoids ambiguity when scanning the SVG. - No gradients. Gradients get flattened to a single fill — usually whichever colour the rasteriser picks as the average. Convert gradients to solid fills before export.
- Convert strokes to filled paths (Object → Outline Stroke in Figma / Illustrator). Strokes have ambiguous mapping to slots.
- Source order matters. The first shape with colour X establishes that colour's slot index. Reorder layers in your design tool to match the intended z-order before exporting.
- One icon per SVG, all at the same artboard size. Hue Type recommends 24×24 or 64×64. The artboard size is the icon's natural "em size".
- Filename = the icon's semantic name (
download.svg,close.svg, etc.) — these become the glyph names inside the font.
Figma export checklist
- Boolean ops resolved (no compound paths with mixed fills)
- Strokes outlined → filled paths
- All fills set to one of your three chosen placeholders
- Export → SVG → uncheck "Include 'id' attribute" (smaller files), keep "Outline text" if you have text glyphs
- Drop the 12 (or however many) SVGs as one batch into Hue Type's upload zone
What CSS designers see
Once the font is built, every developer who uses it can recolour your icons live in the browser. They do that with @font-palette-values + the CSS font-palette property — see the For Developers section. Your job as the source designer ends at the SVG: pick clean 3-colour layers, structure them in the right z-order, and ship.
3. For Designers
Installing on macOS
- Download
yourfont-safari.ttffrom Hue Type. - Double-click → Install Font in Font Book.
- The font is now available system-wide — Figma, Sketch, Illustrator, Pages, etc.
Why the Safari TTF, not WOFF2? WOFF2 is a web-only container. macOS apps use TTF/OTF. The Safari TTF includes SBIX bitmaps, which makes glyphs visible in every macOS app regardless of COLRv1 support.
Using in Figma
Desktop (macOS): install the Safari TTF via Font Book, then pick your font from Figma's font picker. Glyphs render from the SBIX bitmaps.
Web: Figma Web runs in Chrome, which supports COLRv1. Use the Local fonts helper in Figma Desktop preferences to make installed fonts available to the web app.
Typing glyphs: the easiest way is to copy the character from Hue Type's preview panel and paste into Figma. Each icon is a Private Use Area Unicode character (U+E001 → U+E00C).
Using in Illustrator & other apps
Illustrator 2024+, InDesign 2024+, Sketch, and Affinity Designer 2 all support SBIX colour fonts on macOS. After installation: create a text layer → set font → paste the glyph character.
4. For Developers
@font-face setup
@font-face {
font-family: "MyIcons";
src: url("/fonts/yourfont.woff2") format("woff2");
font-display: block;
}
Using glyphs in HTML
<span style="font-family: MyIcons; font-size: 24px;" aria-hidden="true"></span>
Always add aria-hidden="true" — PUA characters have no semantic meaning to screen readers. Pair every icon with an aria-label on the parent button.
CSS font-palette — the basics
.icon {
font-family: MyIcons;
font-palette: normal; /* default — uses CPAL palette index 0 */
}
.icon-brand {
font-palette: --brand-colors;
}
Defining custom palettes
@font-palette-values --brand-colors {
font-family: "MyIcons";
base-palette: 0;
override-colors:
0 rgb(124, 106, 245), /* slot 0 → purple */
1 rgb(226, 236, 91), /* slot 1 → lime */
2 rgb(255, 107, 157); /* slot 2 → pink */
}
.icon { font-palette: --brand-colors; }
The font-family inside @font-palette-values must match your @font-face declaration exactly. Hue Type fonts use 3 colour slots.
Hover crossfade pattern
CSS cannot animate directly between font-palette values. Stack two copies of the icon and crossfade opacity:
.icon-wrap { position: relative; display: inline-flex; width: 24px; height: 24px; }
.icon-rest, .icon-hover {
position: absolute; inset: 0;
transition: opacity 300ms ease-in-out;
}
.icon-rest { opacity: 1; font-palette: --icon-grey; }
.icon-hover { opacity: 0; font-palette: --brand-colors; }
.icon-wrap:hover .icon-rest { opacity: 0; }
.icon-wrap:hover .icon-hover { opacity: 1; }
Inline glyphs in body text
<p>Design <span class="icon" aria-hidden="true"></span>
systems that scale <span class="icon" aria-hidden="true"></span>.</p>
Icons resize automatically with surrounding text — perfect for headings, callouts, and marketing copy.
Animations
Cycle palettes with setInterval + direct fontPalette updates. For the "active glyph spotlight" effect (one icon lit, others dimmed) toggle opacity + transform on a 350 ms interval. See the Hue Type Loader component for the canonical pattern.
5. Browser & Platform Support
COLRv1 (font-palette CSS)
| Browser | Version | Notes |
|---|---|---|
| Chrome / Edge | 98+ | Full support |
| Firefox | 107+ | Full support |
| Opera / Samsung Internet | recent | Chromium-based |
| Safari (macOS & iOS) | All versions | Not supported as of May 2026 |
Global coverage: ~72% of browser sessions (StatCounter, 2026).
SBIX (bitmap colour font)
| Platform | Support |
|---|---|
| macOS / iOS / iPadOS | Full — CoreText renders SBIX natively |
| Safari (macOS) | Yes — both installed and via @font-face |
| Figma / Sketch / Illustrator on macOS | Yes |
| Chrome / Firefox | Partial — may render but prefers COLRv1 if available |
6. SBIX vs COLRv1 — explained
COLRv1
Stores icons as layered vector shapes, each assigned a colour from a CPAL palette table. Infinitely scalable, ~4 KB for 12 icons, fully recolourable via CSS font-palette. Not supported in Safari.
SBIX
Stores PNG bitmaps inside the font file, one per glyph per size ("strike"). Rendered natively on Apple platforms by CoreText. Fixed colours — cannot be recoloured with CSS. Slightly larger file (~40 KB for 12 icons across 4 strikes).
| Scenario | Use |
|---|---|
| Web (Chrome / Firefox / Edge) | WOFF2 (COLRv1) |
| Safari / iOS web | WOFF2 + SBIX TTF fallback (load both, browser picks the one it can render) |
| macOS design tools | TTF — Safari & iOS (SBIX) |
| iOS native app | TTF — Safari & iOS (SBIX) |
7. Safari & iOS — the full picture
COLRv1 glyphs in the Private Use Area are invisible in Safari and all iOS browsers. The property is parsed (so CSS.supports('font-palette', 'normal') returns true in Safari 15.4+), but rendering produces nothing — and PUA codepoints have no OS fallback glyph.
Correct detection
export function detectColrSupport() {
if (typeof window === "undefined") return true;
const ua = navigator.userAgent;
const isIOS =
/iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const isSafari =
/Safari/.test(ua) &&
!/Chrome|Chromium|CriOS|FxiOS|EdgA|OPR/.test(ua);
if (isIOS || isSafari) return false;
return CSS.supports("font-palette", "normal");
}
The Hue Type approach: hot-swap to SBIX on Safari
Load the SBIX TTF dynamically and register it under the same family name. The browser picks whichever face has visible glyphs:
useEffect(() => {
if (detectColrSupport()) return; // Chrome/FF/Edge — keep COLR
const face = new FontFace("MyIcons", "url(/myicons-safari.ttf)", {
display: "block",
});
face.load().then((loaded) => document.fonts.add(loaded));
}, []);
Result: pixel-identical UI on Safari and Chrome. SBIX glyphs carry their baked-in palette colours; CSS font-palette overrides have no visual effect on Safari but remain harmless.
When will Safari support COLRv1?
No confirmed timeline as of May 2026. WebKit bug 242154 has been open since 2022. Recommendation: design with the SBIX swap so today's Safari users see the same UI, then COLRv1 will activate automatically when WebKit ships support.
7. Safari fallback — the production pattern
The exact implementation Hue Type ships today. Use this verbatim if you want pixel-identical icon rendering on Chrome/FF/Edge (COLRv1 + CSS palettes) AND on Safari/iOS (SBIX bitmaps).
Architecture in 6 steps
- Ship two font files:
icons.woff2(COLRv1) andicons-safari.ttf(COLRv1 + SBIX) - Declare
@font-facefor the COLR file under family"MyIcons" - On every page mount, run UA detection
- If Safari/iOS: load the SBIX file under a separate family name
"MyIconsSafari"via theFontFaceAPI - Toggle a class on
<html>(e.g..no-colr) - Two CSS rules under that class do all the work: one swaps the font-family for icon elements, the other substitutes the palette crossfade with a filter transition
Why a separate family name (not "MyIcons" for both)
If you load SBIX under the same family as the COLR face, the browser sees two faces registered for "MyIcons". WebKit's matching algorithm picks the CSS-declared face (first-declared wins), tries to render PUA glyphs from it, and produces nothing — invisible icons.
Users describe the symptom as "icons flash on briefly, then vanish after a second" — that's the SBIX renderring for a beat before the COLR face finishes loading and takes over.
Using "MyIcons" for COLR and "MyIconsSafari" for SBIX eliminates the conflict entirely. A CSS cascade rule under .no-colr flips every icon's font-family to the SBIX one with !important, overriding any inline style.
Detection — the right way
CSS.supports('font-palette', 'normal') returns true in Safari 15.4+ even though COLRv1 still renders nothing. You have to UA-sniff:
export function detectColrSupport(): boolean {
if (typeof window === "undefined") return true; // SSR: assume supported
const ua = navigator.userAgent;
// iOS always uses WebKit regardless of which browser the user chose
const isIOS =
/iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
// macOS Safari — Chrome/Firefox/Edge all include "Safari" in their UA,
// so we have to exclude them explicitly
const isSafari =
/Safari/.test(ua) &&
!/Chrome|Chromium|CriOS|FxiOS|EdgA|OPR/.test(ua);
if (isIOS || isSafari) return false;
return CSS.supports("font-palette", "normal");
}
Loading the SBIX font + flipping the class
let loadStarted = false;
export async function ensureSbixFontLoaded(): Promise<void> {
if (typeof window === "undefined" || loadStarted) return;
loadStarted = true;
try {
const face = new FontFace(
"MyIconsSafari",
"url(/fonts/icons-safari.ttf)",
{ display: "block" },
);
const loaded = await face.load();
document.fonts.add(loaded);
// Tag <html> so the CSS cascade override kicks in
document.documentElement.classList.add("no-colr");
} catch {
// Font failed to load — UI must still be usable via aria-label /
// sibling text on every icon button
}
}
Mount it once at the root
// app/layout.tsx (Next.js)
import { useEffect } from "react";
function SafariFontInit() {
useEffect(() => {
if (!detectColrSupport()) ensureSbixFontLoaded();
}, []);
return null;
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<SafariFontInit />
{children}
</body>
</html>
);
}
The two CSS rules under .no-colr
/* 1. Family swap — every glyph element switches to the SBIX face */
.no-colr .icon-glyph {
font-family: "MyIconsSafari" !important;
}
/* 2. Hover crossfade substitute — SBIX is fixed-colour, so the
opacity-crossfade between two named palettes is invisible. A
saturate / brightness filter gives equivalent visual feedback. */
.no-colr .icon-stack {
transition: filter 300ms ease-in-out,
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
filter: saturate(0.2) brightness(0.92);
opacity: 0.9;
}
.no-colr *:hover > .icon-stack,
.no-colr *:hover .icon-stack,
.no-colr .icon-stack:hover {
filter: none;
opacity: 1;
transform: scale(1.06);
}
Add className="icon-glyph" to every icon span. Wrap any hover-crossfade icon pair in <span class="icon-stack">...</span>. That's the entire integration — Chrome users get the real COLR palette crossfade, Safari users get the filter-based equivalent, neither needs any other code path.
Why !important on the family swap
Components typically set font-family: "MyIcons" as an inline style on each icon span. Inline styles have higher specificity than class-targeted CSS — but !important in author CSS wins over a non-!important inline style. That's the only reason it's needed.
Limitations on Safari
font-paletteoverrides have no visual effect (SBIX is fixed-colour). The runtime palette set in CSS is ignored — glyphs render with the colours baked into the SBIX bitmaps.- Therefore the COLR "grey → brand" hover crossfade can't happen on Safari. The filter-based substitute above is the workaround.
- The SBIX file must include every glyph you reference. If you add a new icon to your COLR font, rebuild the SBIX file too or Safari will render an empty box at that codepoint.
9. React / Next.js integration
Loading the font
/* app/globals.css */
@font-face {
font-family: "MyIcons";
src: url("/fonts/yourfont.woff2") format("woff2");
font-display: block;
}
Preloading
// app/layout.tsx
<link
rel="preload"
href="/fonts/yourfont.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
Dynamic load from a signed URL
const res = await fetch(signedUrl);
const buf = await res.arrayBuffer();
const face = new FontFace(family, buf);
await face.load();
document.fonts.add(face);
Note: document.fonts.check() is unreliable for PUA codepoints. Use document.fonts.forEach() + status check instead.
10. File size & performance
Real measured numbers — 12 COLRv1 glyphs:
| Format | Size | Requests | CSS recolour |
|---|---|---|---|
| 12 SVG files | ~48 KB | 12 | Partial |
| 12 PNG @2x | ~120 KB | 12 | ✗ |
| 1 WOFF2 (COLRv1) | ~4 KB | 1 | ✓ |
| 1 TTF + SBIX (Safari) | ~40 KB | 1 | ✗ |
COLRv1 WOFF2 is 15× smaller than SVGs and 30× smaller than PNGs — a single cached request.
11. Format comparison
| Feature | SVG | PNG | COLRv1 | SBIX |
|---|---|---|---|---|
| Scalable | ✓ | ✗ | ✓ | Strikes only |
| Multi-colour | ✓ | ✓ | ✓ | ✓ |
| CSS recolour | Partial | ✗ | ✓ font-palette | ✗ |
| Single request | ✗ | Sprite only | ✓ | ✓ |
| Inline in HTML | ✓ (bloated) | ✗ | ✓ 1 char | ✓ 1 char |
| CSS animation | JS/CSS | ✗ | ✓ opacity crossfade | ✗ |
| Safari / iOS | ✓ | ✓ | ✗ | ✓ |
| Chrome / Firefox | ✓ | ✓ | ✓ | Partial |
| Size (12 icons) | ~48 KB | ~120 KB | ~4 KB | ~40 KB |
12. FAQ
Can I make icons monochrome?
Override all colour slots with the same value: override-colors: 0 #333, 1 #333, 2 #333;
Does font-palette animate with CSS transitions?
No — use the two-stacked-icons opacity crossfade pattern.
Can I use these in an <img> tag?
No. Use <span> or any inline element. <img> cannot use web fonts.
Will icons render in email clients?
No. Email strips @font-face. For emails, export individual PNGs and use <img> tags.
Why are my icons invisible in Safari?
Safari doesn't render COLRv1, and PUA codepoints have no fallback. Use the SBIX hot-swap pattern in section 6.
What codepoints are the glyphs at?
U+E001 through U+E00C — 12 glyphs total. String.fromCodePoint(0xe001 + i) for any i in 0…11.