diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -37,6 +37,8 @@ const TEXTURE_KEEP_SCREENS: f32 = 3.0;
 
 pub struct CanvasState {
     textures: HashMap<usize, (u64, TextureHandle)>,
+    /// The Navigation pane's page thumbnails, by page ([`page_thumbnail`]).
+    thumbnails: HashMap<usize, (u64, TextureHandle)>,
     /// Visible full-resolution tiles of pages too tall for one GPU texture.
     tiles: HashMap<(usize, usize), (u64, TextureHandle)>,
     /// Page content fingerprints of the current layout, for the texture cache (#811).
@@ -273,6 +275,14 @@ impl CanvasState {
         crate::objects::dragged_off_page(self.obj_drag.as_ref())
     }
 
+    /// The pages the Navigation pane has a thumbnail of.
+    #[cfg(test)]
+    pub(crate) fn thumbnail_pages(&self) -> Vec<usize> {
+        let mut pages: Vec<usize> = self.thumbnails.keys().copied().collect();
+        pages.sort_unstable();
+        pages
+    }
+
     /// How many pages have a cached rendering.
     #[cfg(test)]
     pub(crate) fn texture_count(&self) -> usize {
@@ -286,6 +296,7 @@ impl Default for CanvasState {
     fn default() -> Self {
         CanvasState {
             textures: HashMap::new(),
+            thumbnails: HashMap::new(),
             tiles: HashMap::new(),
             page_keys: PageKeys::default(),
             markup_facts: std::cell::Cell::new(None),
@@ -909,6 +920,46 @@ fn page_key(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, i: usize, vie
     h.finish()
 }
 
+/// Page `i` of `layout` drawn `width` points wide, for the Navigation pane's Pages tab, from a
+/// cache of its own: the page's texture is kept only near the view, at the page's zoom. A
+/// thumbnail out of date (an edit, a change of view) shows until `budget` lets it be drawn again,
+/// one page off the budget each, so typing in a long document waits for no thumbnails.
+pub(crate) fn page_thumbnail(
+    app: &mut WordApp,
+    ctx: &egui::Context,
+    layout: &std::sync::Arc<DocLayout>,
+    i: usize,
+    width: f32,
+    budget: &mut usize,
+) -> Option<TextureHandle> {
+    let page = layout.pages.get(i)?;
+    let scale_px = (width * ctx.pixels_per_point() / page.w.max(1.0)).clamp(0.02, 1.0);
+    let view = view_key(app, false, None);
+    let key = page_key(app, layout, i, view, scale_px);
+    let fresh = app.canvas.thumbnails.get(&i).is_some_and(|(k, _)| *k == key);
+    if !fresh && *budget > 0 {
+        *budget -= 1;
+        let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &page_render_options(app, false));
+        let ci = page_image(&img);
+        match app.canvas.thumbnails.get_mut(&i) {
+            Some((k, tex)) => {
+                tex.set(ci, egui::TextureOptions::LINEAR);
+                *k = key;
+            }
+            None => {
+                let tex = ctx.load_texture(format!("thumbnail{i}"), ci, egui::TextureOptions::LINEAR);
+                app.canvas.thumbnails.insert(i, (key, tex));
+            }
+        }
+    }
+    app.canvas.thumbnails.get(&i).map(|(_, tex)| tex.clone())
+}
+
+/// Drop the thumbnails of pages past the last of `pages`.
+pub(crate) fn keep_thumbnails(app: &mut WordApp, pages: usize) {
+    app.canvas.thumbnails.retain(|i, _| *i < pages);
+}
+
 fn to_screen(origin: Pos2, page_rect: Rect, scale: f32, x: f32, y: f32) -> Pos2 {
     pos2(origin.x + page_rect.min.x + x * scale, origin.y + page_rect.min.y + y * scale)
 }
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
@@ -486,15 +486,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
     });
     ui.separator();
     egui::ScrollArea::vertical().show(ui, |ui| match app.ui.nav_tab.as_str() {
-        "pages" => {
-            let l = app.session.layout();
-            for (i, p) in l.pages.iter().enumerate() {
-                let label = crate::i18n::fmt(tl!("Page {page}"), &[("page", &p.number.to_string())]);
-                if ui.selectable_label(app.session.page_hint == i, label).clicked() {
-                    let _ = app.run("edit.goto", json!({"page": i + 1}));
-                }
-            }
-        }
+        "pages" => page_thumbnails(app, ui),
         "results" => {
             let results = app.session.find.results.clone();
             ui.label(egui::RichText::new(format!("{} results", results.len())).small().weak());
@@ -554,6 +546,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
     });
 }
 
+/// The Pages tab: a picture of each page, as Word's Pages and Thumbnails show; a click goes to
+/// the page, and the page with the caret is outlined. Only thumbnails scrolled into view are
+/// drawn, two a frame at most ([`crate::canvas::page_thumbnail`]).
+fn page_thumbnails(app: &mut WordApp, ui: &mut egui::Ui) {
+    let layout = app.session.layout();
+    crate::canvas::keep_thumbnails(app, layout.pages.len());
+    let width = (ui.available_width() - 32.0).clamp(60.0, 220.0);
+    let mut budget = 2;
+    for (i, page) in layout.pages.iter().enumerate() {
+        let label = crate::i18n::fmt(tl!("Page {page}"), &[("page", &page.number.to_string())]);
+        let current = app.session.page_hint == i;
+        let size = egui::vec2(width, width * page.h / page.w.max(1.0));
+        ui.vertical_centered(|ui| {
+            let (rect, r) = ui.allocate_exact_size(size, egui::Sense::click());
+            if ui.is_rect_visible(rect) {
+                let painter = ui.painter();
+                match crate::canvas::page_thumbnail(app, ui.ctx(), &layout, i, width, &mut budget) {
+                    Some(tex) => {
+                        painter.image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
+                    }
+                    None => {
+                        painter.rect_filled(rect, 0.0, egui::Color32::WHITE);
+                    }
+                }
+                let stroke = if current || r.hovered() {
+                    egui::Stroke::new(if current { 2.5 } else { 1.5 }, ui.visuals().selection.stroke.color)
+                } else {
+                    egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)
+                };
+                painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Outside);
+            }
+            r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &label));
+            if r.clicked() {
+                let _ = app.run("edit.goto", json!({"page": i + 1}));
+            }
+            ui.label(egui::RichText::new(page.number.to_string()).small().weak());
+        });
+        ui.add_space(6.0);
+    }
+    // Thumbnails left out of date wait for the next frame.
+    if budget == 0 {
+        ui.ctx().request_repaint();
+    }
+}
+
 /// Which headings the pane shows: those under a collapsed heading (every following heading
 /// of a deeper level) are hidden (#1997).
 fn visible_headings(levels: &[u8], collapsed: &[bool]) -> Vec<bool> {
@@ -1293,6 +1330,37 @@ mod tests {
         assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed");
     }
 
+    /// The Pages tab shows each page as a picture, the caret's page outlined; a click on one goes
+    /// to that page. Thumbnails are drawn a couple a frame, and only for pages in the pane's view.
+    #[test]
+    #[cfg(not(target_arch = "wasm32"))]
+    fn pages_tab_shows_page_thumbnails_that_go_to_their_page() {
+        use egui_kittest::kittest::Queryable;
+        let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("One")), crate::Services::default());
+        for text in ["Two", "Three"] {
+            app.run("insert.pageBreak", serde_json::json!({})).unwrap();
+            app.run("text.insert", serde_json::json!({"text": text})).unwrap();
+        }
+        app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap();
+        app.ui.nav_tab = "pages".into();
+        let mut h = pane_harness(app);
+        h.run_steps(4);
+        assert_eq!(h.state_mut().session.layout().pages.len(), 3);
+        let drawn = h.state().canvas.thumbnail_pages();
+        assert!(drawn.starts_with(&[0, 1]), "the pages in the pane's view have their pictures: {drawn:?}");
+        assert_eq!(h.state().session.page_hint, 2, "the caret is on the last page");
+        assert!(h.query_by_label("Page 3").is_some());
+
+        h.get_by_label("Page 1").click();
+        h.run_steps(4);
+        assert_eq!(h.state().session.page_hint, 0, "a click goes to its page");
+
+        // An edit leaves the pictures in place until they are drawn again.
+        h.state_mut().run("text.insert", serde_json::json!({"text": "Edited "})).unwrap();
+        h.run_steps(4);
+        assert_eq!(h.state().canvas.thumbnail_pages(), drawn);
+    }
+
     fn pane_harness(app: crate::WordApp) -> egui_kittest::Harness<'static, crate::WordApp> {
         let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
             |ui, app: &mut crate::WordApp| {
