Hue Type

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:

FileFormatUse for
yourfont.woff2COLRv1 WOFF2Websites (Chrome, Firefox, Edge)
yourfont.ttfCOLRv1 TTFDesign tools that need TTF; Chromium apps
yourfont-safari.ttfCOLRv1 + SBIX TTFSafari, 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.

SlotZ-orderTypical role
0Bottom (rendered first)Background / fill plate
1MiddleMid layer / accent
2Top (rendered last)Foreground detail (lines, dots, glyph faces)
Slot 0 background Slot 1 mid layer Slot 2 foreground render order first → last composite all 3 stacked
Each glyph is a vertical stack of vector shapes. Slot 0 renders first (back), slot 2 renders last (front). CSS 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:

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.

Duo-tone · 2 slots
Background plate + foreground accent. Best for filled icons with a clear inner mark — check, plus, close, edit.
↓ duo-sample.svg
Tri-tone · 3 slots
Background + mid layer + foreground. A three-layer stack — ideal for icons with structural depth like cards, stacks, badges.
↓ tri-sample.svg
Illustration · 4+ slots
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.

✗ Don't
Strokes — stroke="..." + stroke-width. Ambiguous slot assignment, stroke width changes during font rasterisation.
✓ Do
Filled paths — every shape has a fill="...", no strokes. Predictable, scales cleanly, slot mapping is deterministic.

Gradients & transparency — avoid both

✗ Don't
Linear / radial gradients get flattened to a single average colour. Semi-transparency creates ambiguity — nanoemoji can't decide if the alpha is part of the slot or a separate layer.
✓ Do
Replace gradients with stacked solid fills — each becomes its own slot. To simulate a gradient feel, use 2 or 3 concentric/overlapping shapes with related shades.

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:

Design rules for predictable results

  1. Exactly 3 distinct fill colours. A 4th colour will either be merged into the closest match or rejected.
  2. 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.
  3. 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.
  4. Convert strokes to filled paths (Object → Outline Stroke in Figma / Illustrator). Strokes have ambiguous mapping to slots.
  5. 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.
  6. 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".
  7. Filename = the icon's semantic name (download.svg, close.svg, etc.) — these become the glyph names inside the font.

Figma export checklist

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

  1. Download yourfont-safari.ttf from Hue Type.
  2. Double-click → Install Font in Font Book.
  3. 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">&#xE001;</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">&#xE001;</span>
   systems that scale <span class="icon" aria-hidden="true">&#xE003;</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)

BrowserVersionNotes
Chrome / Edge98+Full support
Firefox107+Full support
Opera / Samsung InternetrecentChromium-based
Safari (macOS & iOS)All versionsNot supported as of May 2026

Global coverage: ~72% of browser sessions (StatCounter, 2026).

SBIX (bitmap colour font)

PlatformSupport
macOS / iOS / iPadOSFull — CoreText renders SBIX natively
Safari (macOS)Yes — both installed and via @font-face
Figma / Sketch / Illustrator on macOSYes
Chrome / FirefoxPartial — 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).

ScenarioUse
Web (Chrome / Firefox / Edge)WOFF2 (COLRv1)
Safari / iOS webWOFF2 + SBIX TTF fallback (load both, browser picks the one it can render)
macOS design toolsTTF — Safari & iOS (SBIX)
iOS native appTTF — 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

  1. Ship two font files: icons.woff2 (COLRv1) and icons-safari.ttf (COLRv1 + SBIX)
  2. Declare @font-face for the COLR file under family "MyIcons"
  3. On every page mount, run UA detection
  4. If Safari/iOS: load the SBIX file under a separate family name "MyIconsSafari" via the FontFace API
  5. Toggle a class on <html> (e.g. .no-colr)
  6. 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

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:

FormatSizeRequestsCSS recolour
12 SVG files~48 KB12Partial
12 PNG @2x~120 KB12
1 WOFF2 (COLRv1)~4 KB1
1 TTF + SBIX (Safari)~40 KB1

COLRv1 WOFF2 is 15× smaller than SVGs and 30× smaller than PNGs — a single cached request.

11. Format comparison

FeatureSVGPNGCOLRv1SBIX
ScalableStrikes only
Multi-colour
CSS recolourPartial✓ font-palette
Single requestSprite only
Inline in HTML✓ (bloated)✓ 1 char✓ 1 char
CSS animationJS/CSS✓ opacity crossfade
Safari / iOS
Chrome / FirefoxPartial
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.