diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -1287,6 +1287,13 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
 }
 
 /// Layers being dragged to a new place in the stack (timeline rows).
+#[derive(Clone)]
+struct LayerDrag {
+    comp: ItemId,
+    owner: egui::Id,
+    layers: Vec<u64>,
+}
+
 fn layer_drag_id() -> egui::Id {
     egui::Id::unique("tl-layer-drag")
 }
@@ -1490,12 +1497,6 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option<usize
     (at, y)
 }
 
-/// The `egui::Id` of the flag the row drag's release sets, telling the same frame's reorder
-/// drawing code to apply the drop.
-fn layer_drop_id() -> egui::Id {
-    egui::Id::unique("tl-layer-drop")
-}
-
 /// Whether a Project item can replace a layer's source (the same set `layer.replaceSource`
 /// accepts): compositions, footage and solids; not folders, data files or 3D models.
 fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool {
@@ -2274,14 +2275,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 }
                 // Locked layers can't be selected or moved (unlock them with the lock switch).
                 let locked = layer.switches.locked;
-                if row_resp.drag_started() && !locked {
+                if row_resp.drag_started_by(egui::PointerButton::Primary) && !locked {
                     // The selected layers move together; an unselected row moves alone.
                     let moving: Vec<u64> =
                         if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
-                    ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
-                }
-                if row_resp.drag_stopped() {
-                    ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
+                    ctx.data_mut(|d| d.insert_temp(layer_drag_id(), LayerDrag { comp: cid, owner: row_resp.id, layers: moving }));
                 }
                 app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
                 if row_resp.clicked() && !locked {
@@ -3335,17 +3333,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-box"), br));
     }
-    // Layer reorder drag: a line where the layers will land; the release moves them there.
-    if let Some(moving) = ctx.data(|d| d.get_temp::<Vec<u64>>(layer_drag_id())) {
-        let dropped = ctx.data_mut(|d| d.remove_temp::<bool>(layer_drop_id())).unwrap_or(false);
+    // The origin row can scroll out of view, so finish the gesture here, independently of
+    // its Response. Disabled UI, Escape or lost ownership cancels; only a primary release drops layers.
+    if let Some(drag) = ctx.data(|d| d.get_temp::<LayerDrag>(layer_drag_id())) {
+        let (down, released, escape) =
+            ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape)));
+        let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released;
+        let valid = ui.is_enabled() && drag.comp == cid && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner)));
+        if dropped || !valid {
+            ctx.data_mut(|d| d.remove::<LayerDrag>(layer_drag_id()));
+        }
+        let moving = &drag.layers;
         let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
         if let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y)
+            && valid
             && !layer_rows.is_empty()
         {
             let (at, line_y) = insertion(&layer_rows, py, rows_rect.min.y);
             let target = at.and_then(|i| layer_rows[i..].iter().find(|(_, l)| !moving.contains(&l.0)).map(|(_, l)| *l));
             if dropped {
-                ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
                 // Only when the order changes (no empty undo step for a drop in place).
                 let mut order: Vec<u64> = comp.layers.iter().map(|l| l.id.0).filter(|id| !moving.contains(id)).collect();
                 let at = target.and_then(|t| order.iter().position(|id| *id == t.0)).unwrap_or(order.len());
@@ -3361,8 +3367,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
                 p.with_clip_rect(rows_rect).line_segment([pos2(rect.min.x, line_y), pos2(graph_x0, line_y)], Stroke::new(2.0, t.accent));
             }
-        } else if dropped {
-            ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
         }
     }
     // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs
--- a/crates/ui-egui/tests/it/ui_viewer.rs
+++ b/crates/ui-egui/tests/it/ui_viewer.rs
@@ -1718,7 +1718,7 @@ fn timeline_rows_drag_to_reorder_layers() {
     assert_eq!(h.state().session.history.undo.len(), steps + 1);
     // Dropping a layer where it already is changes nothing.
     let b = row(&h, "Box").center();
-    drag(&mut h, b, b + vec2(0.0, 3.0));
+    drag(&mut h, b, b + vec2(20.0, 0.0));
     assert_eq!(names(&h), ["Plate", "Box"]);
     assert_eq!(h.state().session.history.undo.len(), steps + 1);
     // And back to the top.
@@ -1727,6 +1727,221 @@ fn timeline_rows_drag_to_reorder_layers() {
     assert_eq!(names(&h), ["Box", "Plate"]);
 }
 
+fn timeline_layer_order(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
+    h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect()
+}
+
+fn timeline_layer_row(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Rect {
+    rect(h, &format!("timeline.layer.{layer}.row"))
+}
+
+fn start_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2) {
+    h.input_mut().events.push(Event::PointerMoved(from));
+    h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
+    h.step();
+    h.input_mut().events.push(Event::PointerMoved(from + vec2(20.0, 0.0)));
+    h.step();
+    assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "the row drag started");
+}
+
+fn release_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
+    h.input_mut().events.push(Event::PointerMoved(at));
+    h.step();
+    h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
+    h.run_steps(2);
+}
+
+fn scroll_timeline(h: &mut Harness<'_, EffectcraftApp>, dy: f32) {
+    let at = rect(h, "timeline.layerMarquee").center();
+    h.input_mut().events.push(Event::PointerMoved(at));
+    h.input_mut().events.push(Event::MouseWheel {
+        unit: egui::MouseWheelUnit::Point,
+        delta: vec2(0.0, dy),
+        modifiers: Default::default(),
+        phase: egui::TouchPhase::Move,
+        source: egui::MouseWheelSource::Wheel,
+    });
+    h.run_steps(20);
+}
+
+/// Wheel-scroll far enough to stop drawing the row that owns the held drag, then drop at
+/// the other end of the stack. The release must work without that row's Response (#2492).
+fn timeline_reorder_after_scroll(down: bool, multiple: bool) {
+    let mut h = harness();
+    for i in 0..38 {
+        h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap();
+    }
+    h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
+    h.run_steps(3);
+    let before = timeline_layer_order(&h);
+    let count = if multiple { 2 } else { 1 };
+    let moving = if down { before[..count].to_vec() } else { before[before.len() - count..].to_vec() };
+    if multiple {
+        h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap();
+        h.run_steps(2);
+    }
+    if !down {
+        scroll_timeline(&mut h, -10_000.0);
+    }
+    let origin = if down { before[0] } else { *before.last().unwrap() };
+    let from = timeline_layer_row(&h, origin).center();
+    // Setup fills the bounded Undo history; reserve room to count this gesture's steps.
+    h.state_mut().session.history.clear();
+    let steps = h.state().session.history.undo.len();
+    start_timeline_row_drag(&mut h, from);
+    scroll_timeline(&mut h, if down { -10_000.0 } else { 10_000.0 });
+    assert!(h.state().auto.find(&format!("timeline.layer.{origin}.row")).is_none(), "the origin row was culled");
+    assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "scrolling keeps the held drag active");
+    let target = if down { *before.last().unwrap() } else { before[0] };
+    let row = timeline_layer_row(&h, target);
+    let to = row.center() + vec2(0.0, row.height() * if down { 0.4 } else { -0.4 });
+    release_timeline_row_drag(&mut h, to);
+
+    let mut expected: Vec<u64> = before.iter().copied().filter(|id| !moving.contains(id)).collect();
+    let at = if down { expected.len() } else { 0 };
+    expected.splice(at..at, moving);
+    assert_eq!(timeline_layer_order(&h), expected, "the offscreen origin still completes its drop");
+    assert_eq!(h.state().session.history.undo.len(), steps + 1, "one reorder, including a selected group, is one undo step");
+    assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "release clears the drag feedback");
+    h.state_mut().session.execute("edit.undo", json!({})).unwrap();
+    assert_eq!(timeline_layer_order(&h), before);
+    h.state_mut().session.execute("edit.redo", json!({})).unwrap();
+    assert_eq!(timeline_layer_order(&h), expected);
+    h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
+    h.run_steps(3);
+
+    // A later, independent drag must not reuse the old gesture or selected group.
+    let i = if down { expected.len() - 1 } else { 0 };
+    let j = if down { i - 1 } else { 1 };
+    let from = timeline_layer_row(&h, expected[i]).center();
+    let row = timeline_layer_row(&h, expected[j]);
+    drag(&mut h, from, row.center() + vec2(0.0, row.height() * if down { -0.4 } else { 0.4 }));
+    expected.swap(i, j);
+    assert_eq!(timeline_layer_order(&h), expected, "the next drag is independent");
+    assert_eq!(h.state().session.history.undo.len(), steps + 2);
+}
+
+#[test]
+fn timeline_rows_drag_to_bottom_after_scrolling() {
+    for multiple in [false, true] {
+        timeline_reorder_after_scroll(true, multiple);
+    }
+}
+
+#[test]
+fn timeline_rows_drag_to_top_after_scrolling() {
+    for multiple in [false, true] {
+        timeline_reorder_after_scroll(false, multiple);
+    }
+}
+
+#[test]
+fn timeline_rows_drag_cancels_without_a_primary_release() {
+    for escape in [false, true] {
+        let mut h = harness();
+        let before = timeline_layer_order(&h);
+        let from = timeline_layer_row(&h, before[0]).center();
+        let row = timeline_layer_row(&h, before[1]);
+        let to = row.center() + vec2(0.0, row.height() * 0.4);
+        let steps = h.state().session.history.undo.len();
+        start_timeline_row_drag(&mut h, from);
+        h.input_mut().events.push(Event::PointerMoved(to));
+        h.step();
+        if escape {
+            key(&mut h, egui::Key::Escape, Default::default());
+        } else {
+            // egui stops its drag on another button's release too. It isn't a layer drop.
+            for pressed in [true, false] {
+                h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
+                h.step();
+            }
+        }
+        assert_eq!(timeline_layer_order(&h), before, "cancellation must not arrange layers");
+        assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
+        release_timeline_row_drag(&mut h, to);
+        assert_eq!(timeline_layer_order(&h), before, "a later release must not replay a cancelled drag");
+        assert_eq!(h.state().session.history.undo.len(), steps);
+    }
+}
+
+#[test]
+fn timeline_rows_drag_cancels_when_a_modal_opens() {
+    let mut h = harness();
+    let before = timeline_layer_order(&h);
+    let from = timeline_layer_row(&h, before[0]).center();
+    let row = timeline_layer_row(&h, before[1]);
+    let to = row.center() + vec2(0.0, row.height() * 0.4);
+    let steps = h.state().session.history.undo.len();
+    start_timeline_row_drag(&mut h, from);
+    let (modifiers, shortcut) = effectcraft_ui_egui::menus::parse_shortcut("Cmd+Alt+;").unwrap();
+    key(&mut h, shortcut, modifiers);
+    assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings));
+    assert!(!h.ctx.read_response(egui::Id::unique(("row", before[0]))).unwrap().enabled(), "the modal disables the Timeline row");
+    assert!(h.ctx.input(|i| i.pointer.primary_down()), "the modal opened during the held drag");
+    release_timeline_row_drag(&mut h, to);
+    assert_eq!(timeline_layer_order(&h), before, "a release behind the modal must not arrange layers");
+    assert_eq!(h.state().session.history.undo.len(), steps);
+    key(&mut h, egui::Key::Escape, Default::default());
+    assert!(h.state().dialog.is_none());
+    assert_eq!(timeline_layer_order(&h), before, "closing the modal must not replay the drag");
+    assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
+    let from = timeline_layer_row(&h, before[0]).center();
+    let row = timeline_layer_row(&h, before[1]);
+    drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4));
+    assert_eq!(timeline_layer_order(&h), [before[1], before[0]]);
+    assert_eq!(h.state().session.history.undo.len(), steps + 1);
+}
+
+#[test]
+fn timeline_rows_drag_does_not_replay_a_release_while_hidden() {
+    use effectcraft_ui_egui::dock::PanelKind;
+    let mut h = harness();
+    let before = timeline_layer_order(&h);
+    let from = timeline_layer_row(&h, before[0]).center();
+    let steps = h.state().session.history.undo.len();
+    start_timeline_row_drag(&mut h, from);
+    h.state_mut().close_panel(PanelKind::Timeline);
+    h.run_steps(2);
+    assert!(h.state().auto.find("timeline.layerMarquee").is_none());
+    release_timeline_row_drag(&mut h, from);
+    h.state_mut().show_panel(PanelKind::Timeline);
+    h.run_steps(3);
+    assert_eq!(timeline_layer_order(&h), before);
+    assert_eq!(h.state().session.history.undo.len(), steps);
+    assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
+    let from = timeline_layer_row(&h, before[0]).center();
+    let row = timeline_layer_row(&h, before[1]);
+    drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4));
+    assert_eq!(timeline_layer_order(&h), [before[1], before[0]]);
+    assert_eq!(h.state().session.history.undo.len(), steps + 1);
+}
+
+#[test]
+fn timeline_rows_drag_cancels_when_the_composition_changes() {
+    let mut h = harness();
+    let original = h.state().session.active_comp_id().unwrap();
+    let before = timeline_layer_order(&h);
+    h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 4})).unwrap();
+    h.state_mut().session.execute("layer.newSolid", json!({"name": "Other layer", "color": "#406080", "width": 80, "height": 80})).unwrap();
+    let other = h.state().session.active_comp_id().unwrap();
+    let other_before = timeline_layer_order(&h);
+    h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap();
+    h.run_steps(3);
+    let from = timeline_layer_row(&h, before[0]).center();
+    let steps = h.state().session.history.undo.len();
+    start_timeline_row_drag(&mut h, from);
+    h.state_mut().session.execute("comp.open", json!({"comp": other.0})).unwrap();
+    h.run_steps(2);
+    assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
+    release_timeline_row_drag(&mut h, from);
+    assert_eq!(timeline_layer_order(&h), other_before);
+    h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap();
+    h.run_steps(3);
+    assert_eq!(timeline_layer_order(&h), before);
+    assert_eq!(h.state().session.history.undo.len(), steps);
+    assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
+}
+
 fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
     h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
 }
