diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -1757,13 +1757,17 @@ impl FontDb {
     /// loaded face covers a character. `None` when no single face covers them all, and the caller
     /// keeps the per-character fallback.
     ///
+    /// Default-ignorables (variation selectors, joiners) take no glyph of their own: they neither
+    /// choose a face nor disqualify one, so an emoji sequence can be given a colour emoji face
+    /// even though that face maps no variation selector.
+    ///
     /// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces
     /// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.
     pub fn fallback_for_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
-        if chars.is_empty() {
+        let mut set: Vec<char> = chars.iter().copied().filter(|c| !crate::is_default_ignorable(*c)).collect();
+        if set.is_empty() {
             return None;
         }
-        let mut set: Vec<char> = chars.to_vec();
         set.sort_unstable();
         set.dedup();
         let key = (set, exclude, bold, italic);
@@ -1791,6 +1795,33 @@ impl FontDb {
         found
     }
 
+    /// A face drawing an emoji presentation sequence (`1️⃣`, `⚖️`, `👩‍💻`) as the one emoji it is:
+    /// the first colour emoji family that maps every character of the sequence the shaper draws
+    /// (the variation selectors and joiners are the shaper's own), else the run's fallback face.
+    /// `None` when no loaded face covers the characters.
+    ///
+    /// Not cached: the colour families are tried first, a handful of lookups, and the run cache
+    /// under them already keeps the search cheap.
+    pub fn color_emoji_face(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
+        let need: Vec<char> = chars.iter().copied().filter(|c| !c.is_control() && !c.is_whitespace() && !crate::is_default_ignorable(*c)).collect();
+        if need.is_empty() {
+            return None;
+        }
+        let style = match (bold, italic) {
+            (true, true) => "Bold Italic",
+            (true, false) => "Bold",
+            (false, true) => "Italic",
+            (false, false) => "Regular",
+        };
+        for family in crate::COLOR_EMOJI {
+            let face = self.face(family, style);
+            if face.family.eq_ignore_ascii_case(family) && need.iter().all(|c| face.covers(*c)) {
+                return Some(face);
+            }
+        }
+        self.fallback_for_run(&need, exclude, bold, italic)
+    }
+
     /// The face for a run, and the number of loaded faces the search last chose from.
     fn search_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> (Option<Arc<FontFace>>, usize) {
         #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
@@ -2156,6 +2187,31 @@ mod tests {
         assert!(db.run_fallbacks.read().unwrap().map.is_empty());
     }
 
+    #[test]
+    #[cfg(not(target_arch = "wasm32"))]
+    fn an_emoji_sequence_prefers_a_colour_emoji_face() {
+        let db = FontDb::global();
+        let colour = crate::COLOR_EMOJI.iter().find(|f| db.face(f, "Regular").family.eq_ignore_ascii_case(f)).copied();
+        let Some(colour) = colour else {
+            eprintln!("skipped: no colour emoji font installed");
+            return;
+        };
+        let primary = db.face(FALLBACK_FAMILY, "Regular");
+        // "1️⃣": the keycap is the digit and the enclosing mark; the variation selector takes no
+        // glyph of its own, so a face that maps the two characters draws it as one emoji.
+        let keycap: Vec<char> = "1\u{FE0F}\u{20E3}".chars().collect();
+        let face = db.color_emoji_face(&keycap, primary.id(), false, false).expect("a colour emoji face");
+        assert!(face.family.eq_ignore_ascii_case(colour), "{} is not {colour}", face.family);
+        assert!(face.covers('1') && face.covers('\u{20E3}'));
+        // The variation selector neither chooses a face nor disqualifies one.
+        assert_eq!(
+            db.fallback_for_run(&['1', '\u{FE0F}'], primary.id(), false, false).map(|f| f.id()),
+            db.fallback_for_run(&['1'], primary.id(), false, false).map(|f| f.id())
+        );
+        // A text-presentation cluster is not an emoji sequence and keeps the ordinary fallback.
+        assert!(!crate::emoji_sequence(&"\u{2764}\u{FE0E}".chars().collect::<Vec<_>>()));
+    }
+
     /// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
     const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
 
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -21,7 +21,7 @@ pub use fontdb::{
 pub use harfrust::Feature;
 use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
 pub use kurbo::BezPath;
-pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
+pub use script::{COLOR_EMOJI, emoji_sequence, fallback_rank, is_default_ignorable, is_emoji, needs_layout_tables, script_fallbacks};
 use skrifa::MetadataProvider;
 use skrifa::instance::Size;
 pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};
diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs
--- a/crates/fonts/src/script.rs
+++ b/crates/fonts/src/script.rs
@@ -94,7 +94,32 @@ pub fn script_fallbacks(c: char) -> &'static [&'static str] {
 
 /// The platforms' colour emoji fonts. Emoji prefer them over the monochrome symbol fonts that
 /// also cover many emoji (Windows' Segoe UI Symbol has ✅, so it drew in black and white).
-const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];
+pub const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];
+
+/// Characters a shaper draws no glyph for of its own: variation selectors, the joiners, the tags
+/// and the other default-ignorables. A face that lacks one is not missing a glyph, so they never
+/// choose a face and never disqualify one ([`FontFace::covers`](crate::FontFace::covers)).
+pub fn is_default_ignorable(c: char) -> bool {
+    matches!(
+        u32::from(c),
+        0x00AD // soft hyphen
+            | 0x034F // combining grapheme joiner
+            | 0x061C // Arabic letter mark
+            | 0x115F..=0x1160 // Hangul fillers
+            | 0x17B4..=0x17B5 // Khmer inherent vowels
+            | 0x180B..=0x180F // Mongolian free variation selectors, vowel separator
+            | 0x200B..=0x200F // zero-width space, joiners, direction marks
+            | 0x202A..=0x202E // bidi embedding and override controls
+            | 0x2060..=0x206F // word joiner, invisible operators, bidi isolates
+            | 0x3164 // Hangul filler
+            | 0xFE00..=0xFE0F // variation selectors 1–16
+            | 0xFEFF // zero-width no-break space
+            | 0xFFA0 // halfwidth Hangul filler
+            | 0x1BCA0..=0x1BCA3 // shorthand format controls
+            | 0x1D173..=0x1D17A // musical format controls
+            | 0xE0000..=0xE0FFF // tags and variation selectors supplement
+    )
+}
 
 /// Characters drawn as emoji by default (Unicode `Emoji_Presentation`): the BMP ones listed in
 /// emoji-data.txt, and the pictograph blocks from U+1F000.
@@ -139,6 +164,38 @@ pub fn is_emoji(c: char) -> bool {
     )
 }
 
+/// Whether these characters, one grapheme cluster, are an emoji **presentation sequence**: the
+/// text asks for an emoji drawing of characters that are not emoji by themselves — a variation
+/// selector-16 (`⚖️`), a keycap (`1️⃣`), a ZWJ sequence, a flag or a skin-toned emoji. Such a
+/// cluster is shaped as one emoji in a colour emoji font, whose ligature draws the whole thing;
+/// the text font would draw the base and leave the keycap or the joiner without a glyph.
+///
+/// `false` for text presentation (`❤︎`, U+FE0E) and for a lone emoji, which
+/// [`is_emoji`] already sends to a colour emoji face character by character.
+pub fn emoji_sequence(chars: &[char]) -> bool {
+    let mut selectors = 0u8;
+    let mut emoji = false;
+    let mut regional = 0usize;
+    for c in chars.iter().copied() {
+        emoji |= is_emoji(c);
+        match u32::from(c) {
+            0xFE0F => selectors |= 1,             // VARIATION SELECTOR-16: emoji presentation
+            0x20E3 => selectors |= 2,             // COMBINING ENCLOSING KEYCAP
+            0x200D => selectors |= 4,             // ZERO WIDTH JOINER
+            0x1F3FB..=0x1F3FF => selectors |= 8,  // emoji modifier (skin tone)
+            0xE0020..=0xE007F => selectors |= 16, // tag characters (subdivision flags)
+            0x1F1E6..=0x1F1FF => regional += 1,   // regional indicators (flags)
+            _ => {}
+        }
+    }
+    selectors & 1 != 0
+        || selectors & 2 != 0
+        || (selectors & 4 != 0 && emoji)
+        || regional >= 2
+        || (selectors & 8 != 0 && emoji)
+        || (selectors & 16 != 0 && emoji)
+}
+
 /// How well `family` suits `c` as a fallback: 0 for the first preferred family, then the others
 /// in order; then families named after the script ("Noto Sans Tamil"); then everything else.
 pub fn fallback_rank(c: char, family: &str) -> usize {
@@ -195,4 +252,33 @@ mod tests {
             assert!(!is_emoji(c), "{c}");
         }
     }
+
+    #[test]
+    fn emoji_presentation_sequences_are_recognised() {
+        let seq = |s: &str| emoji_sequence(&s.chars().collect::<Vec<_>>());
+        // Keycaps (with and without the variation selector), emoji presentation of a text-default
+        // symbol, a ZWJ sequence, a flag, a skin tone and a tag sequence.
+        assert!(seq("1\u{FE0F}\u{20E3}"));
+        assert!(seq("1\u{20E3}"));
+        assert!(seq("\u{2696}\u{FE0F}"));
+        assert!(seq("\u{1F469}\u{200D}\u{1F4BB}"));
+        assert!(seq("\u{1F1E7}\u{1F1F7}"));
+        assert!(seq("\u{1F44D}\u{1F3FD}"));
+        assert!(seq("\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"));
+        // Text presentation, plain text and a lone joiner are not sequences.
+        assert!(!seq("\u{2764}\u{FE0E}"));
+        assert!(!seq("1"));
+        assert!(!seq("Tema central"));
+        assert!(!seq("\u{1F4D8}"));
+        assert!(!seq("\u{200D}"));
+    }
+
+    #[test]
+    fn default_ignorables_never_choose_a_face() {
+        assert!(is_default_ignorable('\u{FE0F}') && is_default_ignorable('\u{FE0E}'));
+        assert!(is_default_ignorable('\u{200D}'));
+        // The enclosing keycap is drawn by the emoji's ligature: it does need a face.
+        assert!(!is_default_ignorable('\u{20E3}'));
+        assert!(!is_default_ignorable('1') && !is_default_ignorable('\u{1F4D8}'));
+    }
 }
diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs
--- a/crates/layout/src/para.rs
+++ b/crates/layout/src/para.rs
@@ -626,11 +626,17 @@ struct SegKey {
     complex: bool,
     /// Odd bidi level: shaped right to left.
     rtl: bool,
+    /// A cluster drawn as one emoji in a colour emoji face (see [`emoji_sequence`]).
+    emoji: bool,
 }
 
 impl SegKey {
     fn same(&self, o: &SegKey) -> bool {
-        self.face.map(|f| f.id()) == o.face.map(|f| f.id()) && self.small == o.small && self.complex == o.complex && self.rtl == o.rtl
+        self.face.map(|f| f.id()) == o.face.map(|f| f.id())
+            && self.small == o.small
+            && self.complex == o.complex
+            && self.rtl == o.rtl
+            && self.emoji == o.emoji
     }
 }
 
@@ -981,40 +987,72 @@ impl<'a> Builder<'a> {
             }
         };
         // Split by font coverage (fallback faces), small caps case, script formatting and direction.
+        // Emoji presentation sequences ("1️⃣", "⚖️", "👩‍💻") are drawn as the one emoji they are:
+        // the whole cluster takes a colour emoji face covering it, where the text face would draw
+        // its base and leave the keycap (U+20E3) or the joined emoji without a glyph.
+        let clusters = wordcraft_doc::cluster::cluster_starts(text);
+        let mut cluster = 0usize;
+        let mut cluster_end = 0usize;
+        let mut cluster_face: Option<FaceRef> = None;
         let mut seg_start = 0;
         let mut cur: Option<SegKey> = None;
         let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
         for (i, c) in text.char_indices() {
             let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1);
+            if i >= cluster_end {
+                while clusters.get(cluster + 1).is_some_and(|s| *s <= i) {
+                    cluster += 1;
+                }
+                let start = clusters.get(cluster).copied().unwrap_or(i);
+                cluster_end = clusters.get(cluster + 1).copied().unwrap_or(text.len());
+                // A cluster of plain ASCII cannot be an emoji sequence: no allocation, no scan.
+                let cluster_text = text.get(start..cluster_end).unwrap_or("");
+                cluster_face = (!cluster_text.is_ascii())
+                    .then(|| {
+                        let chars: Vec<char> = cluster_text.chars().collect();
+                        wordcraft_fonts::emoji_sequence(&chars)
+                            .then(|| wordcraft_fonts::FontDb::global().color_emoji_face(&chars, primary.id(), rc.bold, rc.italic))
+                            .flatten()
+                    })
+                    .flatten()
+                    .map(|f| FaceRef::of(&f));
+            }
+            // An emoji cluster keeps its own face and its digits as typed: a numeral mode that
+            // rewrote them would break the keycap the shaper draws.
+            let emoji = cluster_face.filter(|_| i < cluster_end);
+            let prev_emoji = cur.is_some_and(|k| k.emoji);
             // A symbol-font character becomes its Unicode symbol (#776).
             let c = show(c);
             // Displayed form (Word › Numeral): one char in, one char out. Coverage and the
             // fallback face are picked for the displayed char, so Hindi digits land in an
             // Arabic-capable face and Western digits in a Latin one.
-            let dc = self.numeral_char(&context, i, c, rtl);
+            let dc = if emoji.is_some() { c } else { self.numeral_char(&context, i, c, rtl) };
             let mark = is_mark(c);
             let complex = match cur {
                 Some(k) if mark => k.complex,
                 _ => rc_cs.is_some() && rc.uses_complex(dc),
             };
             let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };
-            let face = match cur {
-                Some(k) if mark => k.face,
-                _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
-                // The run's one substitute face for a missing Thai font (#1549).
-                _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
-                _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
-                // Stay in the fallback face the word started in while it covers the letters.
-                Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
-                // The run's unified East Asian face, when one face covers the whole run.
-                _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
-                _ => {
-                    let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
-                    wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
-                }
+            let face = match emoji {
+                Some(f) => Some(f),
+                None => match cur {
+                    Some(k) if mark => k.face,
+                    _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
+                    // The run's one substitute face for a missing Thai font (#1549).
+                    _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
+                    _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
+                    // Stay in the fallback face the word started in while it covers the letters.
+                    Some(SegKey { face: Some(f), .. }) if !prev_emoji && f.covers(dc) => Some(f),
+                    // The run's unified East Asian face, when one face covers the whole run.
+                    _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
+                    _ => {
+                        let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
+                        wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
+                    }
+                },
             };
             let small = rc.small_caps && !rc.caps && c.is_lowercase();
-            let k = SegKey { face, small, complex, rtl };
+            let k = SegKey { face, small, complex, rtl, emoji: emoji.is_some() };
             match cur {
                 Some(p) if p.same(&k) => {}
                 Some(p) => {
@@ -1047,7 +1085,7 @@ impl<'a> Builder<'a> {
             // Displayed text: numeral substitution is 1 char → 1 char and a capital may be
             // several; `map_starts` holds where each document char's displayed form starts and
             // `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel).
-            let substituted = expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);
+            let substituted = !key.emoji && (expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c));
             let mut mapped = String::new();
             let mut map_starts: Vec<usize> = Vec::new();
             let mut orig_of: Vec<usize> = Vec::new();
diff --git a/crates/render/src/emoji.rs b/crates/render/src/emoji.rs
new file mode 100644
--- /dev/null
+++ b/crates/render/src/emoji.rs
@@ -0,0 +1,192 @@
+//! Colour emoji for the interface: the same emoji fonts and colour-glyph path the page draws with
+//! ([`crate::color_glyph`]), rasterised into a small straight-alpha RGBA image that the UI can show
+//! as a texture. The Navigation pane uses it for the emoji in heading labels, which the interface
+//! fonts only have monochrome.
+
+use std::sync::Arc;
+
+use vello_cpu::kurbo::{Affine, Rect};
+use vello_cpu::{PixmapMut, Resources};
+
+use crate::color_glyph::{self, Drawable};
+use crate::context_pool::CONTEXTS;
+
+/// A colour emoji raster: straight (not premultiplied) RGBA, row-major, top-down.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct EmojiImage {
+    pub width: u32,
+    pub height: u32,
+    pub rgba: Vec<u8>,
+}
+
+/// The largest side an emoji raster may have, pixels: the UI asks for a line's height, not more.
+const MAX_SIDE: u32 = 512;
+
+/// `text` (an emoji or an emoji sequence such as `⚖️` or `👩‍💻`) drawn from a colour emoji font,
+/// about `px` pixels tall (the em square), cropped to its ink, in straight-alpha RGBA. `None` when
+/// the system has no colour emoji font or the glyphs carry no colour data, so the caller keeps the
+/// interface font's monochrome glyph.
+pub fn emoji_image(text: &str, px: u32) -> Option<EmojiImage> {
+    let px = px.clamp(4, MAX_SIDE) as f64;
+    let chars: Vec<char> = text.chars().collect();
+    if chars.iter().all(|c| c.is_whitespace() || c.is_control()) {
+        return None;
+    }
+    let face = wordcraft_fonts::FontDb::global().color_emoji_face(&chars, u32::MAX, false, false)?;
+    // A monochrome face (the run fallback when no colour emoji family is installed) would draw a
+    // flat silhouette: the interface font's own glyph is better than that.
+    if !wordcraft_fonts::COLOR_EMOJI.contains(&face.family.as_str()) {
+        return None;
+    }
+    let glyphs = wordcraft_fonts::shape(&face, text, &[], |c| c);
+    if glyphs.is_empty() {
+        return None;
+    }
+    let upem = face.upem;
+    if !(upem.is_finite() && upem > 0.0) {
+        return None;
+    }
+    let scale = px / upem;
+
+    // Drawables with their pen positions (font units, y down from the baseline), and the union of
+    // their boxes: the image is sized from that, then cropped to the ink.
+    let mut pen = 0.0f64;
+    let mut placed: Vec<(Arc<Drawable>, (f64, f64))> = Vec::new();
+    let mut bounds: Option<Rect> = None;
+    for g in &glyphs {
+        let origin = (pen + g.x_offset as f64, g.y_offset as f64);
+        if let Some(d) = color_glyph::drawable(&face, g.gid, px, true) {
+            let b = match &*d {
+                Drawable::Bitmap { rect, .. } => Rect::new(rect.x0 + origin.0, rect.y0 + origin.1, rect.x1 + origin.0, rect.y1 + origin.1),
+                // COLR glyphs have no bitmap box: take the em square, trimmed later by the ink.
+                Drawable::Layers(_) => Rect::new(origin.0 - 0.1 * upem, origin.1 - 0.95 * upem, origin.0 + 1.1 * upem, origin.1 + 0.1 * upem),
+            };
+            bounds = Some(match bounds {
+                Some(u) => u.union(b),
+                None => b,
+            });
+            placed.push((d, origin));
+        }
+        pen += g.x_advance as f64;
+    }
+    let b = bounds?;
+    // A wide sequence (a ZWJ family at 512 px) can need more than MAX_SIDE: shrink the drawing so
+    // its ink fits the image instead of being clipped at the right or bottom edge.
+    let raw_w = b.width() * scale + 2.0;
+    let raw_h = b.height() * scale + 2.0;
+    let shrink = (MAX_SIDE as f64 / raw_w).min(MAX_SIDE as f64 / raw_h).min(1.0);
+    let scale = scale * shrink;
+    let w = (b.width() * scale).ceil() + 2.0;
+    let h = (b.height() * scale).ceil() + 2.0;
+    if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) {
+        return None;
+    }
+    let (w, h) = ((w as u32).clamp(1, MAX_SIDE), (h as u32).clamp(1, MAX_SIDE));
+
+    let mut ctx = CONTEXTS.take(w as u16, h as u16);
+    ctx.set_transform(Affine::IDENTITY);
+    // Font units (pen at the origin) → the image, with one pixel of margin.
+    let to_image = Affine::translate((1.0 - b.x0 * scale, 1.0 - b.y0 * scale)) * Affine::scale(scale);
+    for (d, origin) in &placed {
+        color_glyph::draw(&mut ctx, d, to_image * Affine::translate(*origin), upem, vello_cpu::peniko::Color::WHITE, 1.0);
+    }
+    ctx.flush();
+    let mut pixels = vec![0u8; w as usize * h as usize * 4];
+    let mut res = Resources::new();
+    if let Some(pm) = PixmapMut::new(w as u16, h as u16, &mut pixels) {
+        ctx.render(pm, &mut res);
+    }
+    CONTEXTS.give_back(ctx);
+
+    crop_straight(&pixels, w, h)
+}
+
+/// Crop premultiplied RGBA rows to the ink and un-premultiply them.
+fn crop_straight(pixels: &[u8], w: u32, h: u32) -> Option<EmojiImage> {
+    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
+    for y in 0..h {
+        for x in 0..w {
+            if pixels[((y * w + x) * 4 + 3) as usize] != 0 {
+                x0 = x0.min(x);
+                y0 = y0.min(y);
+                x1 = x1.max(x);
+                y1 = y1.max(y);
+            }
+        }
+    }
+    if x0 == u32::MAX {
+        return None;
+    }
+    let (cw, ch) = (x1 - x0 + 1, y1 - y0 + 1);
+    let mut rgba = Vec::with_capacity((cw * ch * 4) as usize);
+    for y in y0..=y1 {
+        for x in x0..=x1 {
+            let i = ((y * w + x) * 4) as usize;
+            let a = pixels[i + 3] as u32;
+            let straight = |v: u8| if a == 0 || a == 255 { v } else { ((v as u32 * 255 + a / 2) / a).min(255) as u8 };
+            rgba.extend_from_slice(&[straight(pixels[i]), straight(pixels[i + 1]), straight(pixels[i + 2]), a as u8]);
+        }
+    }
+    Some(EmojiImage { width: cw, height: ch, rgba })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn has_colour_emoji() -> bool {
+        wordcraft_fonts::FontDb::global().color_emoji_face(&['📘'], u32::MAX, false, false).is_some()
+    }
+
+    /// A colour emoji font, when the system has one, gives a non-empty image with actual colour in
+    /// it — what the Navigation pane paints over the interface font's monochrome glyph.
+    #[test]
+    fn a_colour_emoji_rasterises_in_colour() {
+        if !has_colour_emoji() {
+            return;
+        }
+        let img = emoji_image("📘", 32).expect("the blue book rasterises");
+        assert!(img.width > 4 && img.height > 4, "cropped to the ink: {img:?}");
+        assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
+        let colourful = img.rgba.chunks(4).any(|p| {
+            p[3] > 200 && {
+                let (hi, lo) = (*p[0..3].iter().max().unwrap(), *p[0..3].iter().min().unwrap());
+                hi - lo > 40
+            }
+        });
+        assert!(colourful, "the book is blue, not grey");
+    }
+
+    #[test]
+    fn several_markers_rasterise() {
+        if !has_colour_emoji() {
+            return;
+        }
+        for m in ["📘", "🔄", "⚖️", "📊", "📝"] {
+            assert!(emoji_image(m, 20).is_some(), "{m} must rasterise");
+        }
+    }
+
+    /// Junk input and text-only strings give no image: the caller keeps the monochrome glyph.
+    #[test]
+    fn text_and_blank_strings_have_no_image() {
+        assert!(emoji_image("   ", 16).is_none());
+        assert!(emoji_image("", 16).is_none());
+        if has_colour_emoji() {
+            assert!(emoji_image("conceito", 16).is_none(), "letters are not a colour emoji");
+        }
+    }
+
+    /// The size asked for is respected (the em square, about), and nonsense sizes are clamped
+    /// instead of asking vello for a huge pixmap.
+    #[test]
+    fn the_requested_size_is_respected() {
+        if !has_colour_emoji() {
+            return;
+        }
+        let small = emoji_image("📘", 8).expect("small");
+        let big = emoji_image("📘", 96).expect("big");
+        assert!(big.height > small.height, "bigger size, taller image: {small:?} {big:?}");
+        assert!(emoji_image("📘", 0).is_some(), "clamped, not zero");
+    }
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -17,6 +17,7 @@ use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_layers};
 use wordcraft_metafile::{Item, Picture, PlacedItem, Seg};
 
 mod color_glyph;
+mod emoji;
 pub mod icon_lib;
 
 /// Worker threads for rasterising (0 on the web, where there are no threads).
@@ -575,6 +576,8 @@ pub struct RenderOptions {
     pub read_paper: Option<Rgb>,
 }
 
+pub use emoji::{EmojiImage, emoji_image};
+
 /// Default grey for a dark page's paper.
 pub const DARK_PAPER: u8 = 0x33;
 
diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs
--- a/crates/render/src/tests_color_glyphs.rs
+++ b/crates/render/src/tests_color_glyphs.rs
@@ -98,3 +98,53 @@ fn emoji_page_with(opts: &RenderOptions, cp: wordcraft_doc::CharProps) -> Option
     assert_eq!(boxes.len(), 2, "both emoji use the colour emoji font");
     Some((img, boxes))
 }
+
+/// A keycap sequence (`1️⃣`) is drawn as the one colour emoji it is: the emoji font's ligature
+/// draws digit and enclosing keycap together, where the text font leaves a missing-glyph box
+/// after the digit. Skips when no colour emoji font is installed.
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn a_keycap_is_drawn_as_a_colour_emoji() {
+    let keycap = "1\u{FE0F}\u{20E3}";
+    let chars: Vec<char> = keycap.chars().collect();
+    let colour = FontDb::global().color_emoji_face(&chars, u32::MAX, false, false);
+    if !colour.is_some_and(|f| COLOR_EMOJI.contains(&f.family.as_str())) {
+        eprintln!("skipped: no colour emoji font installed");
+        return;
+    }
+    let mut d = Document::from_text("x");
+    d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("{keycap} Tema"), wordcraft_doc::CharProps::default()))];
+    let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
+    let page = &l.pages[0];
+    let scale = 2.0;
+    let img = render_page(&d, page, scale, &RenderOptions::default());
+    let mut box_of = None;
+    for it in page_display(&d, page, &DisplayOptions::default()) {
+        if let Draw::Glyphs { face, size, glyphs, text, .. } = it
+            && COLOR_EMOJI.contains(&face.family.as_str())
+        {
+            // One glyph draws the whole keycap: its ligature, not the digit alone.
+            assert_eq!(glyphs.len(), 1, "keycap drawn with {} glyphs in {}", glyphs.len(), face.family);
+            assert_eq!(text, keycap, "the colour emoji draws the keycap text");
+            let (_, x, y) = glyphs[0];
+            let pad = 6.0;
+            box_of = Some((
+                ((x * scale - pad).max(0.0) as u32),
+                (((y - size) * scale - pad).max(0.0) as u32),
+                ((x + size) * scale + pad) as u32,
+                (y * scale + pad) as u32,
+            ));
+        }
+    }
+    let (x0, y0, x1, y1) = box_of.expect("the keycap is drawn from the colour emoji font");
+    // The keycap's badge is blue: its blue channel runs well above its red one, where the paper
+    // and the black text are grey.
+    let blue = (x0..x1)
+        .flat_map(|x| (y0..y1).map(move |y| (x, y)))
+        .filter(|(x, y)| {
+            let p = img.pixel(*x, *y);
+            i32::from(p[2]) - i32::from(p[0]) > 20
+        })
+        .count();
+    assert!(blue > 30, "only {blue} blue pixels in the keycap box {x0},{y0}..{x1},{y1}");
+}
diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs
--- a/crates/ui-egui/src/panes.rs
+++ b/crates/ui-egui/src/panes.rs
@@ -603,6 +603,57 @@ fn nav_headings(doc: &Document) -> Vec<(Path, u8, String)> {
     items
 }
 
+/// The heading's label as atoms: a colour emoji cluster is drawn from the system emoji font as an
+/// image (the interface fonts are monochrome, so the outline used to show a box or a black-and-
+/// white glyph where the page showed colour), the rest stays text. The texture is rasterised once
+/// per (cluster, size) and cached on the context.
+fn nav_label_atoms<'a>(ui: &mut Ui, text: &str, level: u8) -> egui::Atoms<'a> {
+    let font = if level == 0 { semibold(12.5) } else { regular(12.5) };
+    let px = 14;
+    let mut atoms: Vec<egui::Atom<'a>> = Vec::new();
+    let mut run = String::new();
+    let flush = |run: &mut String, atoms: &mut Vec<egui::Atom<'a>>| {
+        if !run.is_empty() {
+            atoms.push(egui::Atom::from(egui::RichText::new(std::mem::take(run)).font(font.clone())));
+        }
+    };
+    let starts = wordcraft_doc::cluster::cluster_starts(text);
+    for (i, start) in starts.iter().copied().enumerate() {
+        let end = starts.get(i + 1).copied().unwrap_or(text.len());
+        let cluster = text.get(start..end).unwrap_or("");
+        match nav_emoji_atom(ui, cluster, px) {
+            Some(atom) => {
+                flush(&mut run, &mut atoms);
+                atoms.push(atom);
+            }
+            None => run.push_str(cluster),
+        }
+    }
+    flush(&mut run, &mut atoms);
+    atoms.push(egui::Atom::grow());
+    egui::Atoms::from(atoms)
+}
+
+/// A colour emoji image atom for a grapheme cluster, when the cluster is an emoji presentation
+/// sequence and a colour emoji font draws it; `None` for text and for sequences without colour.
+fn nav_emoji_atom<'a>(ui: &mut Ui, cluster: &str, px: u32) -> Option<egui::Atom<'a>> {
+    let chars: Vec<char> = cluster.chars().collect();
+    if !wordcraft_fonts::emoji_sequence(&chars) {
+        return None;
+    }
+    let key = egui::Id::unique(("nav_emoji", cluster, px));
+    if let Some((tex, size)) = ui.data(|d| d.get_temp::<(egui::TextureHandle, egui::Vec2)>(key)) {
+        return Some(egui::Image::new((tex.id(), size)).into());
+    }
+    let image = wordcraft_render::emoji_image(cluster, px)?;
+    let size = egui::vec2(image.width as f32, image.height as f32);
+    let pixels = egui::ColorImage::from_rgba_unmultiplied([image.width as usize, image.height as usize], &image.rgba);
+    let tex = ui.ctx().load_texture(format!("nav_emoji/{cluster}/{px}"), pixels, egui::TextureOptions::LINEAR);
+    let atom = egui::Image::new((tex.id(), size)).into();
+    ui.data_mut(|d| d.insert_temp(key, (tex, size)));
+    Some(atom)
+}
+
 /// Constrain each heading to the available pane width so long document text cannot
 /// enlarge the panel. A heading too long for the width wraps onto further lines — a word
 /// wider than the pane is broken too — and the row grows with it, so the label reads whole
@@ -624,13 +675,13 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Op
             ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
             toggled = tri.clicked();
         }
-        let label = egui::RichText::new(text).font(if level == 0 { semibold(12.5) } else { regular(12.5) });
+        let atoms = nav_label_atoms(ui, text, level);
         let width = ui.available_width();
         let r = ui.add(
             // The trailing grow atom takes the spare width, so the text starts flush left
             // under its parent instead of centred, as in Word (#1199). A minimum height
             // keeps short headings on one row; a wrapped one becomes as tall as it needs.
-            egui::Button::selectable(selected, (label, egui::Atom::grow())).wrap().min_size(vec2(width, ui.spacing().interact_size.y)),
+            egui::Button::selectable(selected, atoms).wrap().min_size(vec2(width, ui.spacing().interact_size.y)),
         );
         (toggled, r)
     })
@@ -1330,6 +1381,24 @@ mod tests {
         }
     }
 
+    /// A heading with a colour emoji shows it as an image atom, because the interface fonts are
+    /// monochrome; a plain heading stays text (plus the trailing grow atom). Skips the image
+    /// half when the platform has no colour emoji font.
+    #[test]
+    fn nav_labels_draw_colour_emoji_as_images() {
+        let has_colour = wordcraft_fonts::FontDb::global()
+            .fallback_for('\u{2696}', u32::MAX)
+            .is_some_and(|f| wordcraft_fonts::COLOR_EMOJI.contains(&f.family.as_str()));
+        egui::__run_test_ui(|ui| {
+            let plain = super::nav_label_atoms(ui, "Base legal", 0);
+            assert_eq!(plain.len(), 2, "text + grow: {plain:?}");
+            if has_colour {
+                let marked = super::nav_label_atoms(ui, "\u{2696}\u{FE0F} Base legal", 0);
+                assert_eq!(marked.len(), 3, "image + text + grow: {marked:?}");
+            }
+        });
+    }
+
     /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's
     /// text field keeps Ctrl+H for itself.
     #[test]
