diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -209,6 +209,7 @@ impl SheetApp {
             // File tab: the Backstage view replaces everything below the title bar.
             backstage::show(self, ui);
         } else {
+            tabs::document_tabs(self, ui);
             ribbon::show(self, ui);
             widgets::notice_bar(self, ui);
             if self.session.prefs.formula_bar {
diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs
--- a/crates/ui-egui/src/tabs.rs
+++ b/crates/ui-egui/src/tabs.rs
@@ -319,6 +319,163 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
     });
 }
 
+/// The tab strip appears once at least this many workbooks are showing.
+const MIN_DOCUMENT_TABS: usize = 2;
+const DOC_TAB_H: f32 = 30.0;
+const DOC_TAB_MIN_W: f32 = 110.0;
+const DOC_TAB_MAX_W: f32 = 240.0;
+const DOC_TAB_CLOSE: f32 = 18.0;
+
+/// The windows to show as tabs, as `(window id, title, unsaved changes)`: one tab per window, so a
+/// workbook with several windows (View › New Window) has several, titled `Name:1`, `Name:2`.
+/// Windows hidden with View › Hide stay off the strip; View › Unhide brings them back.
+fn document_tab_items(session: &gridcraft_engine::Session) -> Vec<(u64, String, bool)> {
+    let dirty = |uid: u64| session.documents().iter().any(|d| d.uid == uid && d.is_dirty());
+    session.windows().iter().filter(|w| !w.hidden).filter_map(|w| Some((w.id, session.window_title(w.id)?, dirty(w.doc_uid)))).collect()
+}
+
+/// Switches to window `id` from its tab. The cell being edited belongs to the window it was typed
+/// in, so it is committed first; an entry that doesn't commit keeps the user where they are.
+fn activate_document(app: &mut SheetApp, id: u64) {
+    if app.session.active_window() == Some(id) {
+        return;
+    }
+    if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
+        return;
+    }
+    app.run_or_alert("window.activate", json!({"window": id}));
+}
+
+/// Closes window `id` the way its close button does (`window.close`): a workbook's last window
+/// asks about unsaved changes (the Save Changes dialog) before the workbook goes.
+fn close_document(app: &mut SheetApp, id: u64) {
+    if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
+        return;
+    }
+    app.run_or_alert("window.close", json!({"window": id}));
+}
+
+/// Draws the window tab strip under the title bar when several windows are open: click a tab to
+/// switch, its × (or a middle click) to close it, + for a new workbook. A workbook with unsaved
+/// changes shows a dot in place of the ×. With native windows (the desktop) every window has its
+/// own OS window and there is no strip.
+pub fn document_tabs(app: &mut SheetApp, ui: &mut Ui) {
+    if app.native_windows {
+        return;
+    }
+    let items = document_tab_items(&app.session);
+    if items.len() < MIN_DOCUMENT_TABS {
+        return;
+    }
+    let t = Tokens::get(ui.ctx());
+    let active = app.session.active_window();
+    // The active tab is brought into view when the active window or the set of tabs changes, not on
+    // every frame, so the strip can still be scrolled to the tabs beyond it.
+    let reveal_key = egui::Id::unique((active, items.iter().map(|(id, ..)| *id).collect::<Vec<_>>()));
+    let reveal_id = ui.scope_id().with("document_tabs_reveal");
+    let reveal_active = ui.ctx().data_mut(|d| {
+        let changed = d.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);
+        d.insert_temp(reveal_id, reveal_key);
+        changed
+    });
+    let (mut activate, mut close, mut new) = (None, None, false);
+    egui::Panel::top("document_tabs").exact_size(DOC_TAB_H + 2.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
+        let rect = ui.max_rect();
+        ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.ribbon_border));
+        ui.horizontal_centered(|ui| {
+            ui.spacing_mut().item_spacing.x = 0.0;
+            ui.add_space(8.0);
+            let new_w = 32.0;
+            let strip_w = (ui.available_width() - new_w - 8.0).max(DOC_TAB_MIN_W);
+            ui.allocate_ui_with_layout(vec2(strip_w, DOC_TAB_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
+                egui::ScrollArea::horizontal()
+                    .id_salt("document_tabs_horizontal")
+                    .auto_shrink([true, false])
+                    .min_scrolled_width(0.0)
+                    .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
+                    .show(ui, |ui| {
+                        ui.horizontal(|ui| {
+                            ui.spacing_mut().item_spacing.x = 2.0;
+                            for (i, title, dirty) in &items {
+                                let (i, dirty, is_active) = (*i, *dirty, Some(*i) == active);
+                                let font = if is_active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
+                                let text_w = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x;
+                                let w = (text_w + 20.0 + DOC_TAB_CLOSE + 8.0).clamp(DOC_TAB_MIN_W, DOC_TAB_MAX_W);
+                                let (r, resp) = ui.allocate_exact_size(vec2(w, DOC_TAB_H), Sense::click());
+                                if is_active && reveal_active {
+                                    resp.scroll_to_me(None);
+                                }
+                                let tab = Rect::from_min_max(pos2(r.left(), r.top() + 3.0), r.max);
+                                if is_active {
+                                    ui.painter().rect_filled(tab, 4.0, t.tab_active);
+                                    ui.painter().rect_stroke(tab, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
+                                    let band = Rect::from_min_size(pos2(tab.left() + 6.0, tab.top()), vec2(tab.width() - 12.0, 2.5));
+                                    ui.painter().rect_filled(band, 1.0, t.accent);
+                                } else if resp.hovered() {
+                                    ui.painter().rect_filled(tab, 4.0, t.hover);
+                                }
+                                resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, true, is_active, title.as_str()));
+                                // The title leaves room for the close button and is cut with an ellipsis.
+                                let mut job =
+                                    egui::text::LayoutJob::simple_singleline(title.clone(), font, if is_active { t.accent } else { t.text });
+                                job.wrap.max_width = w - 20.0 - DOC_TAB_CLOSE;
+                                job.wrap.max_rows = 1;
+                                job.wrap.break_anywhere = true;
+                                job.wrap.overflow_character = Some('…');
+                                let galley = ui.painter().layout_job(job);
+                                let at = pos2(tab.left() + 10.0, tab.center().y + 1.0 - galley.size().y / 2.0);
+                                ui.painter().galley(at, galley, t.text);
+                                // The close button sits on top of the tab, so it takes its own clicks.
+                                let close_rect = Rect::from_center_size(
+                                    pos2(tab.right() - 6.0 - DOC_TAB_CLOSE / 2.0, tab.center().y + 1.0),
+                                    vec2(DOC_TAB_CLOSE, DOC_TAB_CLOSE),
+                                );
+                                let close_name = app.ui.language.tr("Close");
+                                let close_resp = ui.interact(close_rect, resp.id.with("close"), Sense::click());
+                                close_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, close_name));
+                                if dirty && !resp.hovered() && !close_resp.hovered() {
+                                    ui.painter().circle_filled(close_rect.center(), 4.0, t.text_dim);
+                                } else {
+                                    if close_resp.hovered() {
+                                        ui.painter().rect_filled(close_rect, 3.0, t.pressed);
+                                    }
+                                    icons::paint(ui.painter(), close_rect.shrink(3.0), Icon::Close, t.text_dim);
+                                }
+                                let close_resp = close_resp.on_hover_text(close_name);
+                                if close_resp.clicked() || resp.middle_clicked() {
+                                    close = Some(i);
+                                } else if resp.clicked() || (resp.secondary_clicked() && !is_active) {
+                                    activate = Some(i);
+                                }
+                                resp.context_menu(|ui| {
+                                    if ui.button(app.ui.language.tr("Close")).clicked() {
+                                        close = Some(i);
+                                        ui.close();
+                                    }
+                                });
+                            }
+                        });
+                    });
+            });
+            let new_name = app.ui.language.tr("New Workbook");
+            let plus = crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, new_name, vec2(24.0, 24.0));
+            plus.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, new_name));
+            if plus.clicked() {
+                new = true;
+            }
+        });
+    });
+    if let Some(i) = activate {
+        activate_document(app, i);
+    }
+    if let Some(i) = close {
+        close_document(app, i);
+    }
+    if new && (app.editor.is_none() || app.commit_edit(0, 0, false, false)) {
+        app.run_or_alert("file.new", json!({}));
+    }
+}
+
 /// Draws status mode/messages, zoom controls/view-mode buttons and selection statistics.
 pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
     let t = Tokens::get(ui.ctx());
diff --git a/crates/ui-egui/tests/it/document_tabs.rs b/crates/ui-egui/tests/it/document_tabs.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/document_tabs.rs
@@ -0,0 +1,170 @@
+//! The workbook tab strip: shown from two open workbooks on, clicks switch, close and add.
+use egui::{Event, PointerButton, Pos2, accesskit::Role};
+use egui_kittest::kittest::{NodeT as _, Queryable as _};
+use gridcraft_engine::Session;
+use serde_json::json;
+
+use crate::common::Harness;
+use crate::support;
+
+fn books(n: usize) -> Harness {
+    let mut s = Session::new();
+    for _ in 0..n {
+        s.new_workbook();
+    }
+    support::harness(s, 6)
+}
+
+fn tab_titles(h: &Harness) -> Vec<String> {
+    h.query_all_by_role(Role::Tab).filter_map(|n| n.accesskit_node().label()).collect()
+}
+
+/// The × of a tab: 15 px in from the tab's right edge, on its row (see `document_tabs`).
+fn close_button(h: &Harness, title: &str) -> Pos2 {
+    let r = h.get_by_role_and_label(Role::Tab, title).rect();
+    Pos2::new(r.right() - 15.0, r.center().y + 1.0)
+}
+
+fn move_to(h: &mut Harness, at: Pos2) {
+    h.input_mut().events.push(Event::PointerMoved(at));
+    h.run_steps(4);
+}
+
+fn click_at(h: &mut Harness, at: Pos2) {
+    move_to(h, at);
+    h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() });
+    h.step();
+    h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() });
+    h.run_steps(8);
+}
+
+#[test]
+fn a_single_workbook_shows_no_tab_strip() {
+    assert!(tab_titles(&books(1)).is_empty());
+}
+
+#[test]
+fn every_open_workbook_gets_a_tab() {
+    assert_eq!(tab_titles(&books(3)), ["Book1", "Book2", "Book3"]);
+}
+
+#[test]
+fn clicking_a_tab_switches_workbooks() {
+    let mut h = books(2);
+    assert_eq!(h.state().session.active_index(), 1, "the newest workbook is active");
+    h.get_by_role_and_label(Role::Tab, "Book1").click();
+    h.run_steps(8);
+    assert_eq!(h.state().session.active_index(), 0);
+    h.get_by_role_and_label(Role::Tab, "Book2").click();
+    h.run_steps(8);
+    assert_eq!(h.state().session.active_index(), 1);
+}
+
+#[test]
+fn the_close_button_closes_that_workbook_and_the_strip_goes_with_the_last_sibling() {
+    let mut h = books(2);
+    // Book1 is not the active tab: it becomes active, then closes.
+    let x = close_button(&h, "Book1");
+    click_at(&mut h, x);
+    assert_eq!(h.state().session.documents().len(), 1);
+    assert_eq!(h.state().session.documents()[0].title, "Book2");
+    assert!(tab_titles(&h).is_empty(), "one workbook left: no strip");
+}
+
+#[test]
+fn a_middle_click_closes_a_tab() {
+    let mut h = books(2);
+    h.get_by_role_and_label(Role::Tab, "Book2").click_button(PointerButton::Middle);
+    h.run_steps(8);
+    assert_eq!(h.state().session.documents().len(), 1);
+    assert_eq!(h.state().session.documents()[0].title, "Book1");
+}
+
+#[test]
+fn closing_a_workbook_with_unsaved_changes_asks_first() {
+    let mut h = books(2);
+    h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
+    h.run_steps(4);
+    let x = close_button(&h, "Book2");
+    click_at(&mut h, x);
+    assert_eq!(h.state().session.documents().len(), 2, "still open");
+    assert!(h.state().dialog.as_ref().is_some_and(|d| d.name() == "saveChanges"), "asks about the changes");
+}
+
+#[test]
+fn the_new_button_adds_a_workbook_tab() {
+    let mut h = books(2);
+    let plus = h.get_by_role_and_label(Role::Button, "New Workbook").rect().center();
+    click_at(&mut h, plus);
+    assert_eq!(tab_titles(&h), ["Book1", "Book2", "Book3"]);
+    assert_eq!(h.state().session.active_index(), 2);
+}
+
+#[test]
+fn hidden_windows_have_no_tab() {
+    let mut h = books(3);
+    h.state_mut().run("view.hideWindow", json!({})).unwrap();
+    h.run_steps(6);
+    assert_eq!(tab_titles(&h), ["Book1", "Book2"]);
+}
+
+#[test]
+fn closing_an_earlier_workbook_keeps_the_hidden_one_hidden() {
+    let mut h = books(4);
+    h.state_mut().run("view.hideWindow", json!({})).unwrap();
+    h.run_steps(6);
+    assert_eq!(tab_titles(&h), ["Book1", "Book2", "Book3"]);
+    let x = close_button(&h, "Book1");
+    click_at(&mut h, x);
+    assert_eq!(tab_titles(&h), ["Book2", "Book3"]);
+    let session = &h.state().session;
+    let hidden: Vec<_> = session.windows().iter().filter(|w| w.hidden).filter_map(|w| session.window_title(w.id)).collect();
+    assert_eq!(hidden, ["Book4"], "the window that was hidden is still the hidden one");
+}
+
+#[test]
+fn a_workbook_with_two_windows_has_two_tabs() {
+    let mut h = books(1);
+    h.state_mut().run("view.newWindow", json!({})).unwrap();
+    h.run_steps(6);
+    assert_eq!(tab_titles(&h), ["Book1:1", "Book1:2"]);
+    // Closing one window leaves the workbook open, without asking.
+    let x = close_button(&h, "Book1:2");
+    click_at(&mut h, x);
+    assert_eq!(h.state().session.documents().len(), 1);
+    assert!(tab_titles(&h).is_empty());
+}
+
+#[test]
+fn native_windows_have_no_tab_strip() {
+    let mut s = Session::new();
+    s.new_workbook();
+    s.new_workbook();
+    let mut app = crate::common::app(s);
+    app.native_windows = true;
+    let h = support::app_harness(app, 6);
+    assert!(tab_titles(&h).is_empty());
+}
+
+#[test]
+fn the_strip_scrolls_away_from_the_active_tab_and_stays_there() {
+    // Twelve tabs do not fit in 1200 px; the newest (active) one is revealed first.
+    let mut h = books(12);
+    let first = |h: &Harness| h.get_by_role_and_label(Role::Tab, "Book1").rect();
+    assert!(first(&h).left() < 0.0, "the first tab starts out of view");
+    let over_strip = Pos2::new(600.0, first(&h).center().y);
+    h.input_mut().events.extend([
+        Event::PointerMoved(over_strip),
+        Event::MouseWheel {
+            unit: egui::MouseWheelUnit::Point,
+            delta: egui::vec2(2000.0, 0.0),
+            phase: egui::TouchPhase::Move,
+            source: egui::MouseWheelSource::Wheel,
+            modifiers: Default::default(),
+        },
+    ]);
+    h.run_steps(30);
+    assert!(first(&h).left() >= 0.0, "scrolling brought the first tab into view");
+    h.run_steps(30);
+    assert!(first(&h).left() >= 0.0, "the strip is not snapped back to the active tab");
+}
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -26,6 +26,7 @@ mod dialog_button_row;
 mod dialog_confirm;
 mod dialog_focus;
 mod dialog_move;
+mod document_tabs;
 mod drag_cursor;
 mod error_checking_menu;
 mod file_menu;
