diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -146,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 | 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
 | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
 | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
+| 2026-10-19 | Font name and font size comboboxes filter as you type: the popup opens with a field that narrows the list, names starting with the text first (#933) |
 | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
 | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
 | 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
diff --git a/crates/ui-egui/src/font_filter_tests.rs b/crates/ui-egui/src/font_filter_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/font_filter_tests.rs
@@ -0,0 +1,57 @@
+//! The type-ahead filter for the font name combobox (#933).
+
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+
+use crate::ribbon::{filter_families, filter_sizes};
+
+fn fams() -> Vec<String> {
+    ["Arial".to_string(), "Times New Roman".to_string(), "Calibri".to_string(), "Charcoal".to_string(), "Verdana".to_string()].to_vec()
+}
+
+/// An empty query keeps every family, in its original order (the two theme fonts stay on top).
+#[test]
+fn an_empty_query_keeps_the_whole_list() {
+    let v = filter_families(&fams(), "");
+    assert_eq!(v.len(), 5);
+    assert_eq!(v[0], "Arial");
+    assert_eq!(v[1], "Times New Roman");
+}
+
+/// Typing filters case-insensitively — "CaL" finds "Calibri" (a case-blind match would find
+/// nothing, so the single name below proves the query is folded).
+#[test]
+fn the_query_matches_without_regard_to_case() {
+    let v = filter_families(&fams(), "CaL");
+    assert_eq!(v, vec!["Calibri".to_string()]);
+}
+
+/// Prefixes come before later matches, so the name being typed rises to the top.
+#[test]
+fn a_prefix_ranks_before_a_match_in_the_middle() {
+    let v = filter_families(&fams(), "cha");
+    assert_eq!(v, vec!["Charcoal".to_string()]);
+    let v = filter_families(&fams(), "ar");
+    assert_eq!(v, vec!["Arial".to_string(), "Charcoal".to_string()]);
+}
+
+/// Pasted or padded text still filters: the query is trimmed.
+#[test]
+fn the_query_is_trimmed_before_matching() {
+    let v = filter_families(&fams(), "  ver  ");
+    assert_eq!(v, vec!["Verdana".to_string()]);
+}
+
+/// Nothing matches: the list is empty, not the whole list and not a panic.
+#[test]
+fn no_match_gives_an_empty_list() {
+    assert!(filter_families(&fams(), "zzz").is_empty());
+}
+
+/// The size combobox filters on what is written there, not on the number's binary value.
+#[test]
+fn the_sizes_filter_on_their_text() {
+    let sizes = [10.0_f64, 18.0, 20.0, 24.0, 96.0];
+    assert_eq!(filter_sizes(&sizes, "2"), vec![20.0, 24.0]);
+    assert_eq!(filter_sizes(&sizes, "8"), vec![18.0]);
+    assert_eq!(filter_sizes(&sizes, ""), sizes.to_vec());
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2086,6 +2086,8 @@ mod eyedropper_tests;
 #[cfg(test)]
 mod find_dialog_tests;
 #[cfg(test)]
+mod font_filter_tests;
+#[cfg(test)]
 mod huge_deck_tests;
 #[cfg(test)]
 mod ime_tests;
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
--- a/crates/ui-egui/src/ribbon.rs
+++ b/crates/ui-egui/src/ribbon.rs
@@ -8,7 +8,9 @@ use serde_json::{Value, json};
 use crate::SlideApp;
 use crate::icons::{self, Icon};
 use crate::theme::{self, Tokens};
-use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex};
+use crate::widgets::{
+    self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, dropdown_filtered, group, icon_toggle, rows, small_button, small_button_ex,
+};
 
 pub const TITLE_H: f32 = 38.0;
 
@@ -474,6 +476,47 @@ pub fn font_families(app: &SlideApp) -> Vec<String> {
     v
 }
 
+/// The type-ahead of the font name and font size comboboxes (#933): narrow `items` to what
+/// `query` names — case-insensitively, entries *starting* with the text first and entries
+/// containing it later — while an empty (or blank) query keeps the list exactly as it was, so the
+/// popup reads the same before anything is typed. Returns the positions, so the number-backed
+/// lists keep their own element type.
+fn matching<T: AsRef<str>>(items: &[T], query: &str) -> Vec<usize> {
+    let q = query.trim().to_lowercase();
+    if q.is_empty() {
+        return (0..items.len()).collect();
+    }
+    let mut before = Vec::new();
+    let mut after = Vec::new();
+    for (i, item) in items.iter().enumerate() {
+        let lower = item.as_ref().to_lowercase();
+        if lower.starts_with(&q) {
+            before.push(i);
+        } else if lower.contains(&q) {
+            after.push(i);
+        }
+    }
+    before.extend(after);
+    before
+}
+
+/// [`matching`] for any list of readable entries — the font family names.
+pub fn filter_options<'a, T: AsRef<str>>(items: &'a [T], query: &str) -> Vec<&'a T> {
+    matching(items, query).into_iter().map(|i| &items[i]).collect()
+}
+
+/// [`filter_options`] for a list of font family names.
+pub fn filter_families(fams: &[String], query: &str) -> Vec<String> {
+    filter_options(fams, query).into_iter().cloned().collect()
+}
+
+/// [`matching`] for the font sizes, which the list holds as numbers and the combobox shows as
+/// text (`24`, `8.5`) — so the query matches what the eye reads.
+pub fn filter_sizes(sizes: &[f64], query: &str) -> Vec<f64> {
+    let labels: Vec<String> = sizes.iter().map(|s| widgets::fmt_num(*s, "")).collect();
+    matching(&labels, query).into_iter().map(|i| sizes[i]).collect()
+}
+
 fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
     let en = enabled(app, "format.bold");
     group(ui, |ui| {
@@ -484,22 +527,27 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                 let size = st.get("size").and_then(Value::as_f64);
                 let fams = font_families(app);
                 let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();
-                dropdown(ui, "font", &font, 150.0, en, |ui| {
-                    for (k, f) in fams.iter().enumerate() {
-                        let label = if k == 0 && theme_fonts.len() == 2 {
-                            format!("{f} (Headings)")
-                        } else if k == 1 && theme_fonts.len() == 2 {
-                            format!("{f} (Body)")
-                        } else {
-                            f.clone()
+                dropdown_filtered(ui, "font", &font, 150.0, en, |ui, typed| {
+                    let shown = filter_options(&fams, typed);
+                    let mut theme_shown = false;
+                    let mut separated = false;
+                    for f in shown {
+                        let pos = fams.iter().position(|x| x.as_str() == f.as_str());
+                        let theme = matches!(pos, Some(0) | Some(1));
+                        if theme_shown && !theme && !separated {
+                            ui.separator();
+                            separated = true;
+                        }
+                        let label = match pos {
+                            Some(0) if theme_fonts.len() == 2 => format!("{f} (Headings)"),
+                            Some(1) if theme_fonts.len() == 2 => format!("{f} (Body)"),
+                            _ => f.to_string(),
                         };
                         if ui.button(egui::RichText::new(label)).clicked() {
-                            run(app, "format.font", json!({"family": f}));
+                            run(app, "format.font", json!({ "family": f.as_str() }));
                             ui.close();
                         }
-                        if k == 1 {
-                            ui.separator();
-                        }
+                        theme_shown |= theme;
                     }
                     ui.separator();
                     // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts).
@@ -509,10 +557,11 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                     }
                 });
                 let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
-                dropdown(ui, "size", &size_text, 52.0, en, |ui| {
-                    for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {
-                        if ui.button(widgets::fmt_num(*s, "")).clicked() {
-                            run(app, "format.size", json!({"size": s}));
+                let sizes: Vec<f64> = deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()).copied().collect();
+                dropdown_filtered(ui, "size", &size_text, 52.0, en, |ui, typed| {
+                    for s in filter_sizes(&sizes, typed) {
+                        if ui.button(widgets::fmt_num(s, "")).clicked() {
+                            run(app, "format.size", json!({ "size": s }));
                             ui.close();
                         }
                     }
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -240,6 +240,19 @@ pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
 
 /// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
 pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
+    dropdown_impl(ui, id, current, width, enabled, false, |ui, _| add_items(ui))
+}
+
+/// A [`dropdown`] with a type-ahead filter (#933): the popup opens with a field at the top, and
+/// whatever is typed there reaches `add_items` as its second argument, which narrows its list to
+/// those entries. The text belongs to one opening — a closed popup leaves none behind — and the
+/// field takes focus when the popup opens, so typing filters at once (the app's own shortcuts are
+/// parked while a field is focused, see `SlideApp::keyboard`).
+pub fn dropdown_filtered(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui, &str)) -> Response {
+    dropdown_impl(ui, id, current, width, enabled, true, add_items)
+}
+
+fn dropdown_impl(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, filtered: bool, add_items: impl FnOnce(&mut Ui, &str)) -> Response {
     let t = Tokens::get(ui.ctx());
     let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
     let painter = ui.painter_at(rect);
@@ -260,8 +273,22 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool,
     );
     chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
     let _ = id;
-    egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
-        egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
+    let typed_id = resp.id.with("typeahead");
+    let popup = egui::Popup::menu(&resp).width(width.max(140.0));
+    if !filtered || !popup.is_open() {
+        ui.data_mut(|d| d.remove::<String>(typed_id));
+    }
+    popup.show(|ui| {
+        let mut typed = String::new();
+        if filtered {
+            typed = ui.data_mut(|d| d.get_temp::<String>(typed_id).unwrap_or_default());
+            let te = ui.add(egui::TextEdit::singleline(&mut typed).hint_text("Type to filter…").desired_width(f32::INFINITY));
+            ui.data_mut(|d| d.insert_temp(typed_id, typed.clone()));
+            if !te.has_focus() && typed.is_empty() {
+                te.request_focus();
+            }
+        }
+        egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| add_items(ui, &typed));
     });
     resp
 }
