diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs
--- a/crates/engine/src/edit.rs
+++ b/crates/engine/src/edit.rs
@@ -523,8 +523,10 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
         }
     }
     let n = moving.len();
+    // Limit the shift once, so the group stops at zero together and keeps its spacing.
+    let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
     let placed = moving.into_iter().map(|(tid, mut c)| {
-        c.start = c.start.saturating_add(delta).max(0);
+        c.start = c.start.saturating_add(delta);
         (lands(dest, tid), c)
     });
     put_clips(s, placed.collect());
diff --git a/crates/engine/tests/it/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs
--- a/crates/engine/tests/it/edit_modes.rs
+++ b/crates/engine/tests/it/edit_modes.rs
@@ -413,3 +413,10 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
     run(&mut e, "edit.mode", json!({"mode": "grid"}));
     assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
 }
+
+#[test]
+fn moving_a_group_left_past_zero_keeps_its_spacing() {
+    let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]);
+    run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
+    assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
+}
