diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs
--- a/crates/ui-egui/src/formula_bar.rs
+++ b/crates/ui-egui/src/formula_bar.rs
@@ -156,7 +156,7 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::co
     sh.spill_anchor(at)
 }
 
-fn active_input(app: &SheetApp) -> String {
+pub(crate) fn active_input(app: &SheetApp) -> String {
     let Some(d) = app.session.active() else { return String::new() };
     let Some(sh) = d.wb.active() else { return String::new() };
     let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs
--- a/crates/ui-egui/src/grid/show.rs
+++ b/crates/ui-egui/src/grid/show.rs
@@ -262,6 +262,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
         interact(app, ui, &resp, &geo, sh, &wb);
     }
     update_cursor(app, ui, &resp, &geo, sh);
+    expose_active_cell(app, ui, resp.id, &wb, sh);
     crate::text_box::show(app, ui, &geo, sh);
     if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
         in_cell_editor(app, ui, &geo, sh, &wb);
@@ -733,6 +734,27 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {
     p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside);
 }
 
+/// The grid is painted, so a screen reader sees one blank node. Give it the active cell: address as
+/// the label, shown text as the value, and the formula (when it isn't hidden) as the description.
+fn expose_active_cell(app: &SheetApp, ui: &egui::Ui, id: egui::Id, wb: &Workbook, sh: &Sheet) {
+    let at = app.session.active().map(|d| d.selection.active).unwrap_or_default();
+    let at = sh.merge_at(at).map(|m| m.start).unwrap_or(at);
+    let input = crate::formula_bar::active_input(app);
+    let picture = sh.cell_pictures.contains_key(&at);
+    // A picture cell's text value is its error fallback; the picture's alt text is what it shows.
+    let shown = if picture { input.clone() } else { gridcraft_engine::display::cell_text(wb, sh, at) };
+    let address = at.to_string();
+    let has_formula = !picture && !input.is_empty() && sh.cell(sh.spill_anchor(at).unwrap_or(at)).is_some_and(|c| c.formula.is_some());
+    ui.ctx().accesskit_node_builder(id, |node| {
+        node.set_role(egui::accesskit::Role::Cell);
+        node.set_label(address);
+        node.set_value(shown);
+        if has_formula {
+            node.set_description(input);
+        }
+    });
+}
+
 #[cfg(test)]
 mod trace_arrow_tests {
     use super::*;
diff --git a/crates/ui-egui/tests/it/grid_screen_reader.rs b/crates/ui-egui/tests/it/grid_screen_reader.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/grid_screen_reader.rs
@@ -0,0 +1,49 @@
+//! The painted grid exposes the active cell to assistive tech: address, value and formula (gap 18).
+
+use egui::accesskit::Role;
+use egui_kittest::kittest::{NodeT as _, Queryable as _};
+use gridcraft_engine::Session;
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+#[test]
+fn active_cell_is_announced_with_address_value_and_formula() {
+    let mut s = Session::new();
+    s.new_workbook();
+    s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
+    s.execute("cell.set", json!({"cell": "B2", "input": "=A1*3"})).unwrap();
+    s.execute("selection.set", json!({"range": "B2"})).unwrap();
+    let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+        |ui, app: &mut SheetApp| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        },
+        crate::common::app(s),
+    );
+    h.run_steps(4);
+    let cell = h.get_by_role_and_label(Role::Cell, "B2");
+    let node = cell.accesskit_node();
+    assert_eq!(node.value().as_deref(), Some("6"));
+    assert_eq!(node.data().description(), Some("=A1*3"));
+}
+
+#[test]
+fn array_formula_is_described_in_braces() {
+    let mut s = Session::new();
+    s.new_workbook();
+    s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
+    s.execute("cell.set", json!({"cell": "A2", "input": "4"})).unwrap();
+    s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
+    s.execute("cell.set", json!({"input": "=A1:A2*2", "array": true})).unwrap();
+    s.execute("selection.set", json!({"range": "B1"})).unwrap();
+    let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+        |ui, app: &mut SheetApp| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        },
+        crate::common::app(s),
+    );
+    h.run_steps(4);
+    let node = h.get_by_role_and_label(Role::Cell, "B1");
+    assert_eq!(node.accesskit_node().data().description(), Some("{=A1:A2*2}"));
+}
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
@@ -47,6 +47,7 @@ mod formula_point_headers;
 mod formula_popup;
 mod freeze_handles;
 mod go_to_dialog;
+mod grid_screen_reader;
 mod header_autofit;
 mod header_column_drag;
 mod host_chrome;
