diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -18,6 +18,7 @@ use crate::theme::{self, Tokens};
 mod cell_paint;
 mod cursor;
 mod editor;
+mod fill_preview;
 mod geo;
 mod interact;
 mod keyboard;
diff --git a/crates/ui-egui/src/grid/fill_preview.rs b/crates/ui-egui/src/grid/fill_preview.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/grid/fill_preview.rs
@@ -0,0 +1,160 @@
+use super::*;
+
+/// Maximum source length to scan on each drag frame for a fill-handle preview.
+const MAX_FILL_PREVIEW_SOURCE_CELLS: u64 = 1_000;
+
+/// Returns up to five predicted numeric fill values without changing the workbook.
+///
+/// Preview modes follow the engine's fill operation, including Ctrl/Cmd alternation and source
+/// number formats. Oversized selections, formulas and calendar-dependent series omit the tooltip.
+pub(super) fn fill_preview_text(wb: &Workbook, sh: &Sheet, src: RangeRef, target: RangeRef, alternate: bool) -> Option<String> {
+    // A single-cell source has no intrinsic direction; infer it from the expanded target.
+    let vertical = if src.height() > 1 {
+        true
+    } else if src.width() > 1 {
+        false
+    } else {
+        target.height() > 1
+    };
+    if (vertical && src.width() != 1) || (!vertical && src.height() != 1) {
+        return None;
+    }
+    let source_len = if vertical { src.height() } else { src.width() };
+    if u64::from(source_len) > MAX_FILL_PREVIEW_SOURCE_CELLS {
+        // This runs on every drag frame; avoid allocating and scanning huge selections for a tooltip.
+        return None;
+    }
+    let source: Vec<CellRef> = if vertical {
+        (src.start.row..=src.end.row).map(|row| CellRef::new(row, src.start.col)).collect()
+    } else {
+        (src.start.col..=src.end.col).map(|col| CellRef::new(src.start.row, col)).collect()
+    };
+    let numbers: Vec<f64> = source
+        .iter()
+        .map(|cell| {
+            let stored = sh.cell(*cell)?;
+            if stored.formula.is_some() || !stored.value.is_number() {
+                return None;
+            }
+            stored.value.as_f64()
+        })
+        .collect::<Option<_>>()?;
+    let style_cell = *source.first()?;
+    let format = wb.styles.get(sh.style_id(style_cell)).num_fmt.as_str();
+    let temporal = matches!(
+        gridcraft_engine::numfmt::NumberFormat::parse(format).kind(),
+        gridcraft_engine::numfmt::FormatKind::Date | gridcraft_engine::numfmt::FormatKind::Time
+    );
+    if !alternate && temporal {
+        // The fill engine applies calendar/time-specific rules. Do not show a numeric guess that
+        // would disagree with the values committed when the drag ends.
+        return None;
+    }
+    let (forward, steps) = if vertical {
+        let forward = target.end.row > src.end.row;
+        let steps = if forward { target.end.row - src.end.row } else { src.start.row.saturating_sub(target.start.row) };
+        (forward, steps)
+    } else {
+        let forward = target.end.col > src.end.col;
+        let steps = if forward { target.end.col - src.end.col } else { src.start.col.saturating_sub(target.start.col) };
+        (forward, steps)
+    };
+    if steps == 0 {
+        return None;
+    }
+    // Match the engine's linear-series least-squares step, including flat numeric sequences.
+    let (slope, fitted_first, fitted_last) = if numbers.len() == 1 {
+        (0.0, numbers[0], numbers[0]) // a lone ordinary number is copied by the default fill operation
+    } else {
+        let n = numbers.len() as f64;
+        let mean_x = (n - 1.0) / 2.0;
+        let mean_y = numbers.iter().sum::<f64>() / n;
+        let (numerator, denominator) = numbers
+            .iter()
+            .enumerate()
+            .fold((0.0, 0.0), |(num, den), (i, y)| (num + (i as f64 - mean_x) * (y - mean_y), den + (i as f64 - mean_x).powi(2)));
+        let slope = if denominator == 0.0 { 0.0 } else { numerator / denominator };
+        let fitted_first = mean_y - slope * mean_x;
+        let fitted_last = mean_y + slope * (n - 1.0 - mean_x);
+        (slope, fitted_first, fitted_last)
+    };
+    let base = if forward { fitted_last } else { fitted_first };
+    let preview = (1..=steps.min(5))
+        .map(|step| {
+            let sign = if forward { 1.0 } else { -1.0 };
+            let source_index = if forward { (step as usize - 1) % source.len() } else { source.len() - 1 - ((step as usize - 1) % source.len()) };
+            let raw = if alternate && numbers.len() == 1 && !temporal {
+                // Ctrl/Cmd swaps the normal single-number copy behavior for a +1/-1 series.
+                numbers[0] + f64::from(step) * sign
+            } else if alternate {
+                // Multi-cell sequences are copied in alternate mode rather than extrapolated.
+                numbers[source_index]
+            } else {
+                base + slope * f64::from(step) * sign
+            };
+            let mag = if raw == 0.0 || !raw.is_finite() { 0 } else { raw.abs().log10().floor() as i32 };
+            let precision = 10f64.powi(14 - mag);
+            let rounded = if !precision.is_finite() || precision == 0.0 { raw } else { (raw * precision).round() / precision };
+            let value = Value::number(rounded);
+            let target_format = wb.styles.get(sh.style_id(source[source_index])).num_fmt.as_str();
+            gridcraft_engine::display::value_text_as(wb, &value, target_format)
+        })
+        .collect::<Vec<_>>();
+    Some(preview.join("\n"))
+}
+
+#[cfg(test)]
+mod fill_preview_tests {
+    use super::*;
+
+    fn preview(values: &[f64], target_end: u32, alternate: bool) -> Option<String> {
+        let wb = Workbook::default();
+        let mut sh = Sheet::new("Sheet1");
+        for (row, value) in values.iter().enumerate() {
+            sh.set_value(CellRef::new(row as u32, 0), Value::number(*value));
+        }
+        fill_preview_text(
+            &wb,
+            &sh,
+            RangeRef::new(CellRef::new(0, 0), CellRef::new(values.len() as u32 - 1, 0)),
+            RangeRef::new(CellRef::new(0, 0), CellRef::new(target_end, 0)),
+            alternate,
+        )
+    }
+
+    #[test]
+    fn alternate_preview_copies_multi_cell_sequences() {
+        assert_eq!(preview(&[1.0, 2.0, 3.0], 5, true).as_deref(), Some("1\n2\n3"));
+    }
+
+    #[test]
+    fn alternate_preview_counts_up_from_a_single_number() {
+        assert_eq!(preview(&[1.0], 3, true).as_deref(), Some("2\n3\n4"));
+        assert_eq!(preview(&[1.0], 3, false).as_deref(), Some("1\n1\n1"));
+    }
+
+    #[test]
+    fn alternate_preview_copies_a_single_temporal_value() {
+        let mut wb = Workbook::default();
+        let mut sh = Sheet::new("Sheet1");
+        let cell = CellRef::new(0, 0);
+        let value = 45_292.0;
+        sh.set_value(cell, Value::number(value));
+        let style = wb.styles.intern(Style { num_fmt: gridcraft_engine::model::NumFmt::new("yyyy-mm-dd"), ..Style::default() });
+        sh.set_style(cell, style);
+
+        let expected = gridcraft_engine::display::value_text_as(&wb, &Value::number(value), "yyyy-mm-dd");
+        assert_eq!(
+            fill_preview_text(&wb, &sh, RangeRef::new(cell, cell), RangeRef::new(cell, CellRef::new(1, 0)), true).as_deref(),
+            Some(expected.as_str())
+        );
+    }
+
+    #[test]
+    fn oversized_source_does_not_create_a_preview() {
+        let wb = Workbook::default();
+        let sh = Sheet::new("Sheet1");
+        let whole_column = RangeRef::cols(0, 0);
+        assert!(fill_preview_text(&wb, &sh, whole_column, whole_column, false).is_none());
+    }
+}
diff --git a/crates/ui-egui/src/grid/selection.rs b/crates/ui-egui/src/grid/selection.rs
--- a/crates/ui-egui/src/grid/selection.rs
+++ b/crates/ui-egui/src/grid/selection.rs
@@ -1,3 +1,4 @@
+use super::fill_preview::fill_preview_text;
 use super::geo::Geo;
 #[cfg(test)]
 use super::interact::{hidden_col_edge, hidden_row_edge};
@@ -145,6 +146,19 @@ pub(crate) fn paint_selection(
             4.0,
             3.0,
         ));
+        if let crate::grid::Drag::Fill { menu: false, .. } = &app.grid.drag
+            && let Some(pointer) = p.ctx().pointer_hover_pos()
+            && p.clip_rect().contains(pointer)
+            && let Some(document) = app.session.active()
+            && let Some(preview) = fill_preview_text(&document.wb, sh, sel.current(), *tr, p.ctx().input(|i| i.modifiers.command))
+        {
+            egui::Area::new(egui::Id::unique("fill_preview_tooltip")).order(egui::Order::Tooltip).fixed_pos(pointer + vec2(14.0, 18.0)).show(
+                p.ctx(),
+                |ui| {
+                    egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(preview));
+                },
+            );
+        }
     }
     if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag {
         let r = sel.current();
diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs
--- a/crates/ui-egui/tests/it/fill_options_button.rs
+++ b/crates/ui-egui/tests/it/fill_options_button.rs
@@ -3,7 +3,7 @@
 //! Days, Fill Weekdays, Fill Months or Fill Years.
 
 use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
-use egui_kittest::kittest::Queryable;
+use egui_kittest::kittest::{NodeT as _, Queryable};
 use gridcraft_engine::Session;
 use gridcraft_engine::core::{CellRef, Value};
 use gridcraft_ui_egui::{SheetApp, grid::Geo};
@@ -159,6 +159,80 @@ fn a_number_drag_offers_no_date_fills() {
     assert!(h.query_by_label("Fill Days").is_none());
 }
 
+#[test]
+/// The preview follows the source direction and agrees with the values committed on release.
+fn fill_handle_shows_numeric_values_while_dragging_without_committing_them() {
+    let mut h = harness(|s| {
+        s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [3]]})).unwrap();
+        s.execute("selection.set", json!({"range": "B1:B3"})).unwrap();
+    });
+    let (start, end) = (corner(&h, "B3"), center(&h, "B6"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+
+    assert_eq!(values(&h, &["B4", "B5", "B6"]), [Value::Empty, Value::Empty, Value::Empty], "previewing does not mutate the sheet");
+    let preview = h.get_by_label("4\n5\n6").accesskit_node().value().unwrap();
+
+    release(&mut h, end, PointerButton::Primary);
+    assert_eq!(values(&h, &["B4", "B5", "B6"]), [n(4.0), n(5.0), n(6.0)]);
+    let sh = h.state().session.active().unwrap().wb.active().unwrap();
+    let committed = ["B4", "B5", "B6"]
+        .map(|cell| gridcraft_engine::display::value_text(&h.state().session.active().unwrap().wb, sh, CellRef::parse(cell).unwrap()))
+        .join("\n");
+    assert_eq!(preview, committed, "the displayed preview matches the fill result");
+}
+
+#[test]
+/// A horizontal fill from one numeric cell uses the target's direction to calculate the preview.
+fn one_cell_horizontal_fill_shows_a_preview() {
+    let mut h = harness(|s| {
+        s.execute("cell.set", json!({"cell": "C1", "input": "5"})).unwrap();
+        s.execute("selection.set", json!({"cell": "C1"})).unwrap();
+    });
+    let (start, end) = (corner(&h, "C1"), center(&h, "E1"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    assert!(h.query_by_label("5\n5").is_some(), "the tooltip follows the horizontal target range");
+    release(&mut h, end, PointerButton::Primary);
+    assert_eq!(values(&h, &["D1", "E1"]), [n(5.0), n(5.0)]);
+}
+
+#[test]
+/// Extrapolated numeric sequences use the same fitted endpoint as the fill engine.
+fn irregular_series_preview_matches_the_committed_values() {
+    let mut h = harness(|s| {
+        s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [4]]})).unwrap();
+        s.execute("home.numberFormat", json!({"range": "B2", "code": "0.00%"})).unwrap();
+        s.execute("selection.set", json!({"range": "B1:B3"})).unwrap();
+    });
+    let (start, end) = (corner(&h, "B3"), center(&h, "B5"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    let preview = h.get_all_by_label_contains("\n").next().unwrap().accesskit_node().value().unwrap();
+
+    release(&mut h, end, PointerButton::Primary);
+    let document = h.state().session.active().unwrap();
+    let sheet = document.wb.active().unwrap();
+    let committed = ["B4", "B5"].map(|cell| gridcraft_engine::display::value_text(&document.wb, sheet, CellRef::parse(cell).unwrap())).join("\n");
+    assert_eq!(preview, committed);
+
+    let mut h = harness(|s| {
+        s.execute("range.setValues", json!({"range": "B3", "values": [[1], [2], [4]]})).unwrap();
+        s.execute("home.numberFormat", json!({"range": "B4", "code": "0.00%"})).unwrap();
+        s.execute("selection.set", json!({"range": "B3:B5"})).unwrap();
+    });
+    let (start, end) = (corner(&h, "B5"), center(&h, "B1"));
+    press(&mut h, start, PointerButton::Primary);
+    move_to(&mut h, start, end);
+    let preview = h.get_all_by_label_contains("\n").next().unwrap().accesskit_node().value().unwrap();
+
+    release(&mut h, end, PointerButton::Primary);
+    let document = h.state().session.active().unwrap();
+    let sheet = document.wb.active().unwrap();
+    let committed = ["B2", "B1"].map(|cell| gridcraft_engine::display::value_text(&document.wb, sheet, CellRef::parse(cell).unwrap())).join("\n");
+    assert_eq!(preview, committed, "backward fills also use each source cell's format");
+}
+
 fn formatted(s: &mut Session) {
     s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap();
     s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();
