diff --git a/ash/drag_drop/drag_drop_controller.cc b/ash/drag_drop/drag_drop_controller.cc
index 37e154e0f15fa..abecd57f1c547 100644
--- a/ash/drag_drop/drag_drop_controller.cc
+++ b/ash/drag_drop/drag_drop_controller.cc
@@ -10,10 +10,16 @@
 #include "ash/drag_drop/drag_drop_tracker.h"
 #include "ash/drag_drop/drag_image_view.h"
 #include "ash/public/cpp/ash_features.h"
+#include "ash/screen_util.h"
 #include "ash/shell.h"
 #include "ash/shell_delegate.h"
+#include "ash/wm/splitview/split_view_constants.h"
+#include "ash/wm/splitview/split_view_drag_indicators.h"
+#include "ash/wm/splitview/split_view_utils.h"
 #include "base/bind.h"
 #include "base/metrics/histogram_macros.h"
+#include "base/no_destructor.h"
+#include "base/optional.h"
 #include "base/pickle.h"
 #include "base/run_loop.h"
 #include "base/strings/utf_string_conversions.h"
@@ -58,6 +64,16 @@ const int kCancelAnimationFrameRate = 60;
 static const float kTouchDragImageScale = 1.2f;
 static const int kTouchDragImageVerticalOffset = -25;
 
+// The following distances are copied from tablet_mode_window_drag_delegate.cc.
+// TODO(https://crbug.com/1069869): share these constants.
+
+// Items dragged to within |kDistanceFromEdgeDp| of the screen will get snapped
+// even if they have not moved by |kMinimumDragToSnapDistanceDp|.
+constexpr float kDistanceFromEdgeDp = 16.f;
+// The minimum distance that an item must be moved before it is snapped. This
+// prevents accidental snaps.
+constexpr float kMinimumDragToSnapDistanceDp = 96.f;
+
 // Adjusts the drag image bounds such that the new bounds are scaled by |scale|
 // and translated by the |drag_image_offset| and and additional
 // |vertical_offset|.
@@ -86,6 +102,37 @@ void DispatchGestureEndToWindow(aura::Window* window) {
     window->delegate()->OnGestureEvent(&gesture_end);
   }
 }
+
+bool IsChromeTabDrag(const ui::OSExchangeData& drag_data) {
+  if (!features::IsWebUITabStripTabDragIntegrationEnabled())
+    return false;
+
+  base::Pickle pickle;
+  drag_data.GetPickledData(ui::ClipboardFormatType::GetWebCustomDataType(),
+                           &pickle);
+  base::PickleIterator iter(pickle);
+
+  uint32_t entry_count = 0;
+  if (!iter.ReadUInt32(&entry_count))
+    return false;
+
+  for (uint32_t i = 0; i < entry_count; ++i) {
+    base::StringPiece16 type;
+    base::StringPiece16 data;
+    if (!iter.ReadStringPiece16(&type) || !iter.ReadStringPiece16(&data))
+      return false;
+
+    // TODO(https://crbug.com/1069869): share this constant between Ash
+    // and Chrome instead of hardcoding it in both places.
+    static const base::NoDestructor<base::string16> chrome_tab_type(
+        base::ASCIIToUTF16("application/vnd.chromium.tab"));
+    if (type == *chrome_tab_type)
+      return true;
+  }
+
+  return false;
+}
+
 }  // namespace
 
 class DragDropTrackerDelegate : public aura::WindowDelegate {
@@ -200,6 +247,7 @@ int DragDropController::StartDragAndDrop(
   drag_data_ = std::move(data);
   drag_operation_ = operation;
   current_drag_actions_ = 0;
+  is_chrome_tab_drag_ = IsChromeTabDrag(*drag_data_);
 
   start_location_ = screen_location;
   current_location_ = screen_location;
@@ -215,6 +263,14 @@ int DragDropController::StartDragAndDrop(
   for (aura::client::DragDropClientObserver& observer : observers_)
     observer.OnDragStarted();
 
+  if (is_chrome_tab_drag_) {
+    // TODO(crbug.com/1069869): move tab dragging logic to delegate or observer.
+    split_view_drag_indicators_ = std::make_unique<SplitViewDragIndicators>(
+        Shell::GetPrimaryRootWindow());
+    split_view_drag_indicators_->SetDraggedWindow(
+        drag_image_->GetWidget()->GetNativeView());
+  }
+
   if (should_block_during_drag_drop_) {
     base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
     quit_closure_ = run_loop.QuitClosure();
@@ -486,16 +542,48 @@ void DragDropController::DragUpdate(aura::Window* target,
       observer.OnDragActionsChanged(op);
   }
 
+  gfx::Point root_location_in_screen = event.root_location();
+  ::wm::ConvertPointToScreen(target->GetRootWindow(), &root_location_in_screen);
+
   DCHECK(drag_image_.get());
   if (drag_image_->GetVisible()) {
-    gfx::Point root_location_in_screen = event.root_location();
-    ::wm::ConvertPointToScreen(target->GetRootWindow(),
-                               &root_location_in_screen);
     current_location_ = root_location_in_screen;
     drag_image_->SetScreenPosition(root_location_in_screen -
                                    drag_image_offset_);
     drag_image_->SetTouchDragOperation(op);
   }
+
+  if (is_chrome_tab_drag_) {
+    aura::Window* const drag_image_window =
+        drag_image_->GetWidget()->GetNativeView();
+    const gfx::Rect area =
+        screen_util::GetDisplayWorkAreaBoundsInScreenForActiveDeskContainer(
+            drag_image_window);
+
+    // These should only be seen in tablet mode which precludes dragging between
+    // displays.
+    DCHECK_EQ(drag_image_window->GetRootWindow(), target->GetRootWindow());
+
+    SplitViewController::SnapPosition snap_position =
+        ::ash::GetSnapPositionForLocation(
+            Shell::GetPrimaryRootWindow(), root_location_in_screen,
+            start_location_,
+            /*snap_distance_from_edge=*/kDistanceFromEdgeDp,
+            /*minimum_drag_distance=*/kMinimumDragToSnapDistanceDp,
+            /*horizontal_edge_inset=*/area.width() *
+                    kHighlightScreenPrimaryAxisRatio +
+                kHighlightScreenEdgePaddingDp,
+            /*vertical_edge_inset=*/area.height() *
+                    kHighlightScreenPrimaryAxisRatio +
+                kHighlightScreenEdgePaddingDp);
+    split_view_drag_indicators_->SetWindowDraggingState(
+        SplitViewDragIndicators::ComputeWindowDraggingState(
+            true, SplitViewDragIndicators::WindowDraggingState::kFromTop,
+            snap_position));
+
+    // TODO(https://crbug.com/1069869): scale source window up/down similar to
+    // |TabletModeBrowserWindowDragDelegate::UpdateSourceWindow()|.
+  }
 }
 
 void DragDropController::Drop(aura::Window* target,
@@ -512,8 +600,6 @@ void DragDropController::Drop(aura::Window* target,
   aura::client::DragDropDelegate* delegate =
       aura::client::GetDragDropDelegate(target);
   if (delegate) {
-    const bool is_chrome_tab_drag = IsChromeTabDrag();
-
     ui::DropTargetEvent e(*drag_data_.get(), event.location_f(),
                           event.root_location_f(), drag_operation_);
     e.set_flags(event.flags());
@@ -521,9 +607,11 @@ void DragDropController::Drop(aura::Window* target,
 
     ui::OSExchangeData copied_data(drag_data_->provider().Clone());
     drag_operation_ = delegate->OnPerformDrop(e, std::move(drag_data_));
-    if (drag_operation_ == 0 && is_chrome_tab_drag) {
+    if (drag_operation_ == 0 && is_chrome_tab_drag_) {
       Shell::Get()->shell_delegate()->CreateBrowserForTabDrop(
           drag_source_window_, copied_data);
+      // TODO(https://crbug.com/1069869): snap the created browser if
+      // necessary.
       StartCanceledAnimation(kCancelAnimationDuration);
     } else if (drag_operation_ == 0) {
       StartCanceledAnimation(kCancelAnimationDuration);
@@ -633,42 +721,17 @@ void DragDropController::Cleanup() {
     drag_window_->RemoveObserver(this);
   drag_window_ = NULL;
   drag_data_.reset();
+
+  if (is_chrome_tab_drag_) {
+    split_view_drag_indicators_->SetWindowDraggingState(
+        SplitViewDragIndicators::WindowDraggingState::kNoDrag);
+    split_view_drag_indicators_.reset();
+    is_chrome_tab_drag_ = false;
+  }
+
   // Cleanup can be called again while deleting DragDropTracker, so delete
   // the pointer with a local variable to avoid double free.
   std::unique_ptr<DragDropTracker> holder = std::move(drag_drop_tracker_);
 }
 
-bool DragDropController::IsChromeTabDrag() {
-  if (!features::IsWebUITabStripTabDragIntegrationEnabled())
-    return false;
-
-  if (!drag_data_)
-    return false;
-  base::Pickle pickle;
-  drag_data_->GetPickledData(ui::ClipboardFormatType::GetWebCustomDataType(),
-                             &pickle);
-  base::PickleIterator iter(pickle);
-
-  uint32_t entry_count = 0;
-  if (!iter.ReadUInt32(&entry_count))
-    return false;
-
-  for (uint32_t i = 0; i < entry_count; ++i) {
-    base::StringPiece16 type;
-    base::StringPiece16 data;
-    if (!iter.ReadStringPiece16(&type) || !iter.ReadStringPiece16(&data)) {
-      return false;
-    }
-
-    // TODO(https://crbug.com/1069869): share this constant between Ash
-    // and Chrome instead of hardcoding it in both places.
-    static const base::string16 chrome_tab_type =
-        base::ASCIIToUTF16("application/vnd.chromium.tab");
-    if (type == chrome_tab_type)
-      return true;
-  }
-
-  return false;
-}
-
 }  // namespace ash
diff --git a/ash/drag_drop/drag_drop_controller.h b/ash/drag_drop/drag_drop_controller.h
index 87a9542af1250..2d1475f462577 100644
--- a/ash/drag_drop/drag_drop_controller.h
+++ b/ash/drag_drop/drag_drop_controller.h
@@ -34,6 +34,7 @@ namespace ash {
 class DragDropTracker;
 class DragDropTrackerDelegate;
 class DragImageView;
+class SplitViewDragIndicators;
 
 class ASH_EXPORT DragDropController : public aura::client::DragDropClient,
                                       public ui::EventHandler,
@@ -111,8 +112,6 @@ class ASH_EXPORT DragDropController : public aura::client::DragDropClient,
   // Helper method to reset everything.
   void Cleanup();
 
-  bool IsChromeTabDrag();
-
   bool enabled_ = false;
   std::unique_ptr<DragImageView> drag_image_;
   gfx::Vector2d drag_image_offset_;
@@ -120,6 +119,10 @@ class ASH_EXPORT DragDropController : public aura::client::DragDropClient,
   int drag_operation_;
   int current_drag_actions_ = 0;
 
+  // Tab drag specific members.
+  bool is_chrome_tab_drag_ = false;
+  std::unique_ptr<SplitViewDragIndicators> split_view_drag_indicators_;
+
   // Window that is currently under the drag cursor.
   aura::Window* drag_window_;
 
diff --git a/ash/wm/splitview/split_view_utils.cc b/ash/wm/splitview/split_view_utils.cc
index a71e49f4b4a9d..ecaebff6e2636 100644
--- a/ash/wm/splitview/split_view_utils.cc
+++ b/ash/wm/splitview/split_view_utils.cc
@@ -412,19 +412,16 @@ void ShowAppCannotSnapToast() {
       kAppCannotSnapToastDurationMs, base::Optional<base::string16>()));
 }
 
-SplitViewController::SnapPosition GetSnapPosition(
+SplitViewController::SnapPosition GetSnapPositionForLocation(
     aura::Window* root_window,
-    aura::Window* window,
     const gfx::Point& location_in_screen,
-    const gfx::Point& initial_location_in_screen,
+    const base::Optional<gfx::Point>& initial_location_in_screen,
     int snap_distance_from_edge,
     int minimum_drag_distance,
     int horizontal_edge_inset,
     int vertical_edge_inset) {
-  if (!ShouldAllowSplitView() ||
-      !SplitViewController::Get(root_window)->CanSnapWindow(window)) {
+  if (!ShouldAllowSplitView())
     return SplitViewController::NONE;
-  }
 
   const bool horizontal = SplitViewController::IsLayoutHorizontal();
   const bool right_side_up = SplitViewController::IsLayoutRightSideUp();
@@ -476,9 +473,9 @@ SplitViewController::SnapPosition GetSnapPosition(
     drag_end_near_edge = true;
   }
 
-  if (!drag_end_near_edge && window->GetRootWindow() == root_window) {
+  if (!drag_end_near_edge && initial_location_in_screen) {
     // Check how far the window has been dragged.
-    const auto distance = location_in_screen - initial_location_in_screen;
+    const auto distance = location_in_screen - *initial_location_in_screen;
     const int primary_axis_distance = horizontal ? distance.x() : distance.y();
     const bool is_left_or_top =
         SplitViewController::IsPhysicalLeftOrTop(snap_position);
@@ -491,4 +488,27 @@ SplitViewController::SnapPosition GetSnapPosition(
   return snap_position;
 }
 
+SplitViewController::SnapPosition GetSnapPosition(
+    aura::Window* root_window,
+    aura::Window* window,
+    const gfx::Point& location_in_screen,
+    const gfx::Point& initial_location_in_screen,
+    int snap_distance_from_edge,
+    int minimum_drag_distance,
+    int horizontal_edge_inset,
+    int vertical_edge_inset) {
+  if (!SplitViewController::Get(root_window)->CanSnapWindow(window)) {
+    return SplitViewController::NONE;
+  }
+
+  base::Optional<gfx::Point> initial_location_in_current_screen = base::nullopt;
+  if (window->GetRootWindow() == root_window)
+    initial_location_in_current_screen = initial_location_in_screen;
+
+  return GetSnapPositionForLocation(
+      root_window, location_in_screen, initial_location_in_current_screen,
+      snap_distance_from_edge, minimum_drag_distance, horizontal_edge_inset,
+      vertical_edge_inset);
+}
+
 }  // namespace ash
diff --git a/ash/wm/splitview/split_view_utils.h b/ash/wm/splitview/split_view_utils.h
index d5065514e31a3..f9d8b02d9bfe9 100644
--- a/ash/wm/splitview/split_view_utils.h
+++ b/ash/wm/splitview/split_view_utils.h
@@ -7,6 +7,7 @@
 
 #include "ash/ash_export.h"
 #include "ash/wm/splitview/split_view_controller.h"
+#include "base/optional.h"
 #include "ui/aura/window_observer.h"
 #include "ui/compositor/layer_animation_observer.h"
 #include "ui/gfx/transform.h"
@@ -138,14 +139,30 @@ ASH_EXPORT bool ShouldAllowSplitView();
 // not compatible.
 ASH_EXPORT void ShowAppCannotSnapToast();
 
-// Returns the desired snap position. To be able to get snapped (meaning the
-// return value is not |SplitViewController::NONE|), |window| must 1) first of
-// all satisfy |SplitViewController::CanSnapWindow| on the split view controller
-// for |root_window|, and 2) secondly be dragged either inside
-// |snap_distance_from_edge| or dragged toward the edge for at least
-// |minimum_drag_distance| distance until it's dragged into a suitable edge of
-// the work area of |root_window| (i.e., |horizontal_edge_inset| if dragged
-// horizontally to snap, or |vertical_edge_inset| if dragged vertically).
+// Calculates the snap position for a dragged window at |location_in_screen|,
+// ignoring any properties of the window itself. The |root_window| is of the
+// current screen. |initial_location_in_screen| is the location at drag start if
+// the drag began in |root_window|, and is empty otherwise. To be snappable
+// (meaning the return value is not |SplitViewController::NONE|),
+// |location_in_screen| must be either inside |snap_distance_from_edge| or
+// dragged toward the edge for at least |minimum_drag_distance| distance until
+// it's dragged into a suitable edge of the work area of |root_window| (i.e.,
+// |horizontal_edge_inset| if dragged horizontally to snap, or
+// |vertical_edge_inset| if dragged vertically).
+ASH_EXPORT SplitViewController::SnapPosition GetSnapPositionForLocation(
+    aura::Window* root_window,
+    const gfx::Point& location_in_screen,
+    const base::Optional<gfx::Point>& initial_location_in_screen,
+    int snap_distance_from_edge,
+    int minimum_drag_distance,
+    int horizontal_edge_inset,
+    int vertical_edge_inset);
+
+// Returns the desired snap position. To be snappable, |window| must 1)
+// satisfy |SplitViewController::CanSnapWindow| for |root_window|, and
+// 2) be snappable according to |GetSnapPositionForLocation| above.
+// |initial_location_in_screen| is the window location at drag start in
+// its initial window. Otherwise, the arguments are the same as above.
 ASH_EXPORT SplitViewController::SnapPosition GetSnapPosition(
     aura::Window* root_window,
     aura::Window* window,