The MVC page editor ships with a pile of drag and drop defects:

  • The whole page shifts the moment you pick a widget up, so your drop target moves out from under the cursor
  • Row, column and container all highlight at once, and nothing tells you which one takes the drop
  • The highlight strobes on and off as you cross zone boundaries
  • The thing following your pointer is a pale bar the width of the page instead of something widget shaped
  • Outlines stay on the canvas after the drop until you reload
  • Spacing in the editor doesn't match what publishes

None of it is your template's fault. It's the default behavior of the Telerik RadDock controls the editor is built on, controls that have not been meaningfully touched since MVC widgets shipped.

All of it is fixable. Most from your own theme stylesheet, the rest from around 250 lines of javascript, because the strobing is a timing problem and a stylesheet can't put a delay on a class it doesn't own. What you end up with: the page holds still, zones fade in and out instead of strobing, the drag helper is a small chip under your cursor, outlines clear themselves, and editor spacing matches published spacing.

Everything below assumes a plain MVC Sitefinity site on a stock Bootstrap 5 resource package. No custom widget framework, nothing exotic in the project. If you've got a Bootstrap 5 package and some .row and .col-* layouts, this applies to you.

The page editor is not an iframe

This trips up most people, and it matters a lot.

Your page gets composed directly into the editor document. Which means two things at once. Your theme stylesheet is already loaded and already applying, so any CSS you write is live in the editor with no extra plumbing. And Sitefinity's editor markup is sitting in the same DOM as your markup, wrapping it.

That second part is where nearly every problem below comes from. Telerik wraps every layout and every widget in extra nesting that has no counterpart on the published page:

<div class="col-md-6 d-flex flex-column gap-3">   <- yours
  <div class="RadDockZone">                       <- injected by the editor
    <div class="RadDock">...widget 1...</div>      <- injected
    <div class="RadDock">...widget 2...</div>      <- injected
    <div class="RadDock rdPlaceHolder">...</div>   <- injected, one per zone
  </div>
</div>

Watch what happens to the gap. On the live page that column has two flex children and spaces them 16px apart. In the editor it has exactly ONE flex child, the .RadDockZone, and your widgets are grandchildren. The gap has nothing left to space. So your carefully spaced column renders flush in the editor, an author looks at it, reasonably decides the spacing is broken, and starts "fixing" it.

Bootstrap's own row gutters survive this, because .g-4 puts padding on the .col-* itself and the injected zone sits inside that padding. Anything that spaces children instead of padding them breaks: d-flex flex-column gap-3 and friends, plus any custom grid of your own built on gap.

Give yourself a way to win specificity

Before you write any fixes, add a class of your choosing to <body> in your page template. Call it whatever you like, I'm using mytheme below. Or hang an id on your outer wrapper if that suits your markup better. It just needs to exist.

Sitefinity's own editor rules are mostly three classes deep and they are NOT marked !important. One extra class on your selector outranks them cleanly and you don't start an !important war you'll regret in six months:

/* Sitefinity: .sfPageContainer .RadDockZone { ... }      = 0,3,0
   Yours:      body.mytheme.sfPageEditor ... .RadDockZone = 0,4,0 */
body.mytheme.sfPageEditor .sfPageContainer .RadDockZone {
  margin-bottom: 0;
}

sfPageEditor is a class Sitefinity puts on <body> in the editor, so that's how you scope anything to editor-only. A few of the fixes below do genuinely need !important because Sitefinity marked its own rule that way, and I've called those out.

Putting back the spacing the editor ate

Since the injected .RadDockZone swallows your gap, you have to put the spacing back manually between the docks. Sibling margin is what works:

/* Restores the column gap that the injected zone ate.
   margin-top, not bottom, so nothing trails the last item.
   The placeholder is excluded at both ends; it collapses to zero height
   during a drag, and spacing a zero-height box reintroduces a jump. */
body.mytheme.sfPageEditor
  .sfPageContainer
  .gap-3
  > .RadDockZone
  > .RadDock:not(.rdPlaceHolder)
  ~ .RadDock:not(.rdPlaceHolder) {
  margin-top: 1rem;
}

Keying off the Bootstrap utility class means you write it once and every column using that utility is covered. If your layouts use a few different gap sizes, write one of these per size, or key off your own layout class instead.

While you're in there, kill the spacing that goes the other way. Sitefinity puts a 12px bottom margin under every widget dock and another 12px under every empty drop zone. Neither one ships to the published page, so your authors are judging their spacing against 12px of padding that's about to vanish:

body.mytheme.sfPageEditor .sfPageContainer .zeControlDock,
body.mytheme.sfPageEditor .sfPageContainer .RadDockZone {
  margin-bottom: 0;
}

The widget title bars already separate the docks visually, so you lose nothing by removing it.

The whole page jumps when you pick anything up

This is the ugliest default in the editor and the cause genuinely surprised me.

Telerik pre-renders a drop placeholder inside every single zone on the page. I counted 35 of them on one page, all sitting there display: none at rest. Start a drag and Telerik puts ALL of them into flow at once, then marks every one except the hovered zone's as visibility: hidden.

Hidden, but still taking up space. So picking up one widget injects an invisible box into every other zone on the page simultaneously, and everything shifts under your cursor before you've really moved it.

Collapse the resting placeholder to a true zero:

body.mytheme.sfPageEditor .sfPageContainer .RadDock.rdPlaceHolder {
  height: 0 !important;
  margin: 0 !important;
  border-width: 0 !important;

  /* Not cosmetic. The placeholder's own box is 0px here, but it holds a 14px
     "Drop here" label that escapes the zeroed box and grows the zone the
     instant Telerik flips display. Clipping it makes the collapsed
     placeholder contribute a true 0px. Measured: 13.5px down to 0. */
  overflow: hidden;
}

Notice what that rule does not touch: display. That's deliberate, and it's the trap. Telerik decides which placeholder is the live one by writing display inline from javascript. Override display with !important and you've taken that decision away from it, so now every zone on the page lights up at once. Zero out the box, leave display alone.

Then bring the placeholder back on the zone that's actually the drop target:

body.mytheme.sfPageEditor
  .sfPageContainer
  .zePlaceholderHighlighted.sfCurrentDragZone:not(.zeDockZoneEmpty):not(
    :has(.zePlaceholderHighlighted)
  )
  .RadDock.rdPlaceHolder {
  height: 58px !important;
  margin: 0 12px 1rem !important;
  border-width: 1px !important;
}

That :not(:has(.zePlaceholderHighlighted)) is doing real work, it isn't tidying. Sitefinity flags every ancestor zone up the chain with the same highlight classes, so without it the rule reads as "any placeholder anywhere under the drag" and restores all 35 again, which is the exact problem you just fixed.

The 1px nudge on drag-approach

Smaller one, but it makes the editor feel loose. Sitefinity ships these three rules, all !important:

.sfPageContainer .RadDockZone                              { padding: 1px }
.sfPageContainer .RadDockZone.zePlaceholderHighlighted     { padding: 0   }
.sfPageContainer .zeDockZoneEmpty.zePlaceholderHighlighted { padding: 1px }

So drag-enter removes 1px of padding on all four sides and the zone's contents step outward. Reads like a border vanishing and everything getting slightly bigger.

That third rule is Sitefinity trying to undo the second one, and it can never match. Sitefinity strips .zeDockZoneEmpty at the same moment it adds .zePlaceholderHighlighted, so those two classes are never on an element together. Put the padding back yourself, on every zone:

body.mytheme.sfPageEditor .sfPageContainer .RadDockZone.zePlaceholderHighlighted {
  padding: 1px !important;
}

The `.zeDockZoneEmpty` trap

That generalizes, and it's probably the most useful thing in this post, so it gets its own heading.

.zeDockZoneEmpty is a rest state flag. Sitefinity removes it the instant a drag enters the zone and adds it back afterwards. So any styling you hang off it disappears at exactly the moment it was doing its job.

I hit this three separate times before the pattern clicked. An empty-state height floor keyed on that class collapsed cards from 120px to 71px mid-drag, so everything below jumped under the cursor. Empty-zone styling vanished the second a drag arrived, leaving the placeholder fill flooding a white card.

Test for emptiness by content instead. It survives Sitefinity's class swapping because it describes what's actually in the zone:

/* "holds no dock of its own that isn't the placeholder" */
.RadDockZone:not(:has(> .RadDock:not(.rdPlaceHolder)))

Stacked dashed outlines that never go away

Sitefinity marks every zone up the ancestor chain with .zePlaceholderHighlighted.sfCurrentDragZone, so one drop target draws a stack of nested dashed outlines. On a Bootstrap layout that's usually three deep, container and row and column, all outlined at once. Sitefinity also never clears those flags after the drop, so the outlines just sit on the canvas until you reload.

The placeholder fill already marks the live target, so the outlines are noise:

body.mytheme.sfPageEditor .sfPageContainer .zePlaceholderHighlighted,
body.mytheme.sfPageEditor .sfPageContainer .sfCurrentDragZone {
  /* transparent, not `border: none`. The width has to stay, or every ancestor
     loses 2px on hover and the whole page shifts. */
  border-color: transparent !important;
}

Then bring the outline back on the one zone where it's actually useful, an empty leaf zone, using the content test from above instead of .zeDockZoneEmpty:

body.mytheme.sfPageEditor
  .sfPageContainer
  .RadDockZone.sfCurrentDragZone:not(:has(> .RadDock:not(.rdPlaceHolder))) {
  border-color: var(--bs-primary) !important;
  background-color: rgb(13 110 253 / 6%);
}

Colors only here, never the border width. Rest state can be a fractional border width, so writing 1px resizes the border box on drag-enter and shifts the page. Same reason the rule above says transparent instead of border: none.

That page-wide bar you drag around

When you drag a widget, the thing following your cursor should look like a widget. By default it's a pale bar spanning the full page width with a tiny label way over at the far left.

There's no ghost and no clone involved here. Telerik drags the real element. On drag start it measures the dock, writes that measurement back as an inline width, bumps the z-index and reparents the live node, then adds a class to it. That inline width is the widget's full layout width, which is why you get a bar.

An author !important rule outranks an inline style, so this is fixable. The editor-dragging class comes from the script in the next section:

body.mytheme.sfPageEditor.editor-dragging .RadDock.rdDragHelper {
  width: fit-content !important;
  max-width: 220px !important;
  min-width: 0 !important;
  height: auto !important;
  overflow: hidden;

  border: 1px solid var(--bs-border-color);
  border-radius: var(--bs-border-radius);
  background: var(--bs-body-bg);
  box-shadow:
    0 1px 2px rgb(0 0 0 / 8%),
    0 8px 20px -4px rgb(0 0 0 / 18%);
}

Three things about that, and the third is why this section doesn't end here.

The class name is .rdDragHelper, which I got by reading RadDock.prototype._draggedCssClass in the browser console on a live editor page. It is not documented anywhere. Guessing gets you .rdDrag or .rdDockDragWrapper, neither of which matches anything, and your rule just sits there dead. Check that property against your own Sitefinity version before you write the selector.

You don't need to clean the overrides up. Telerik restores the inline width and z-index on drag end and removes the class, so all of it lapses on its own.

And now your chip is in the wrong place. Telerik never stores your grab point. On mousedown the dragged element just keeps whatever offset it had from the cursor, and every mousemove applies a delta to wherever the element currently sits. That's invisible at full width, because the bar is under the pointer no matter where you grabbed it. Shrink an 800px title bar down to 100px though, and a grab at x=400 leaves the chip stranded 400px to the left of your pointer, still faithfully tracking your movements from over there. Fixing the width is what creates this, so the CSS above is only half the fix. Other half is anchorToCursor in the script.

Why a stylesheet can't finish the job

Everything so far has been CSS, and CSS runs out of road at four specific points.

The strobing is a timing problem, not an appearance one. RadDock has no drag hysteresis at all. A zone lights on the very first hit-test frame that touches it and goes dark on the first frame that misses, so a cursor traveling across zone borders makes the highlight flicker. Then it gets worse. Lighting a populated zone inflates its placeholder by around 80px, which reflows everything below it and can slide a different zone under a stationary cursor, which re-triggers the whole thing. That's the feedback loop behind zones that seem to chase your pointer around, and no stylesheet can add a delay to a class it doesn't control.

The drag-helper rule needs a hook. Telerik adds .rdDragHelper to the dock, but nothing on body says a drag is in progress, so there's no way to scope drag-only rules from CSS alone.

The chip needs repositioning, which is the stranding problem from the last section. That's arithmetic against a live mouse event.

And the placeholder height transition silently refuses to run. Telerik flips the placeholder from display: none to block and your class arrives in the same style recalculation, so the browser has no start value to interpolate from and jumps straight to the end. It needs a forced style flush in between, which is a javascript-only trick.

So that's what the script is for. Four jobs, and none of them are clever: it debounces the un-highlight so a zone stays lit for a beat while your mouse settles, forces a style flush so the placeholder height actually animates instead of snapping, puts a class on body for the drag-only CSS to key off, and yanks the shrunken chip back under your cursor once at drag start.

Note which edge gets the debounce. Lighting a zone is immediate, at ENTER_DELAY_MS = 0, because a delay there just reads as lag. It's the un-lighting that waits, 80ms, and re-entering inside that window cancels it outright. That's the whole trick behind zones that stop flickering when you drag across a boundary.

It's around 250 lines, and it patches Telerik prototypes, which means it is going to look extremely deletable to whoever finds it next. Leave a header comment explaining why it's there.

(function () {
  'use strict';

  // Enter is immediate. A non-zero enter delay splits the drop feedback into a
  // staged three-beat pop, because zePlaceholderHighlighted is not just a
  // highlight: Sitefinity gates the "Drop here" label on it, our placeholder
  // inflate rule keys on it, and Telerik sets the placeholder's display from JS
  // synchronously. Delaying the class delays two of the three beats and leaves
  // the third at t=0. Tested at 100ms and it reads as laggy.
  var ENTER_DELAY_MS = 0;
  // The exit linger is the part that earns its keep. It stops the highlight
  // flickering off and on when the cursor grazes a zone boundary. It is also a
  // delay the user feels on every leave, and it stacks on the fade-out
  // transition, so keep the two together near 100ms total.
  var LEAVE_LINGER_MS = 80;
  var DRAGGING_CLASS = 'editor-dragging';
  var CURSOR_OFFSET_PX = 10;

  // Defence in depth. Never let this run on a published page.
  function isPageEditor() {
    var body = document.body;
    if (!body) {
      return false;
    }
    return body.classList.contains('mytheme') && body.classList.contains('sfPageEditor');
  }

  var patched = false;
  // Zones with a pending or applied highlight, so a drag end can flush them all.
  var trackedZones = [];

  function state(zone) {
    if (!zone._highlight) {
      zone._highlight = { enterTimer: null, leaveTimer: null, cssClass: null, lit: false };
    }
    return zone._highlight;
  }

  function clearTimers(st) {
    if (st.enterTimer) {
      clearTimeout(st.enterTimer);
      st.enterTimer = null;
    }
    if (st.leaveTimer) {
      clearTimeout(st.leaveTimer);
      st.leaveTimer = null;
    }
  }

  // Runs fn with the zone's own class helpers shadowed, and returns the class
  // name the original code tried to add or remove. This is how only the
  // highlight gets deferred while everything else stays synchronous.
  function captureHighlightClass(zone, fn) {
    var captured = null;
    zone.addCssClass = function (cssClass) {
      captured = cssClass;
    };
    zone.removeCssClass = function (cssClass) {
      captured = cssClass;
    };
    try {
      fn();
    } finally {
      delete zone.addCssClass;
      delete zone.removeCssClass;
    }
    return captured;
  }

  function light(zone, cssClass) {
    var st = state(zone);
    st.cssClass = cssClass;
    st.lit = true;

    // Load-bearing, not a stray debug line. Telerik's _showPlaceholder has just
    // flipped the placeholder from display:none to block; without a flush here
    // the height change lands in the same style recalc, the transition has no
    // start value to interpolate from, and it silently does not run. Reading
    // the placeholder's own computed height is what establishes that start
    // value. Reading document.body.offsetHeight also forces layout but does
    // not establish it, and cost 66ms on a full page.
    var placeholder = zone.get_element().querySelector(':scope > .RadDock.rdPlaceHolder');
    if (placeholder) {
      void getComputedStyle(placeholder).height;
    }
    zone.addCssClass(cssClass);
  }

  function unlight(zone) {
    var st = state(zone);
    if (st.cssClass) {
      zone.removeCssClass(st.cssClass);
    }
    st.lit = false;
  }

  function scheduleEnter(zone, cssClass) {
    var st = state(zone);
    // Re-entering during the linger window is a no-op: the zone is still lit,
    // which is the whole point of a sticky target.
    clearTimers(st);
    if (trackedZones.indexOf(zone) === -1) {
      trackedZones.push(zone);
    }
    if (st.lit) {
      return;
    }
    // Zero means synchronous, not setTimeout(0). The highlight has to land in
    // the same frame as the placeholder Telerik just showed, and a 0ms timer is
    // still a separate macrotask and a separate paint.
    if (ENTER_DELAY_MS <= 0) {
      light(zone, cssClass);
      return;
    }
    st.enterTimer = setTimeout(function () {
      st.enterTimer = null;
      light(zone, cssClass);
    }, ENTER_DELAY_MS);
  }

  function scheduleLeave(zone) {
    var st = state(zone);
    clearTimers(st);
    // Left before the enter delay elapsed, so it never lit. Nothing to undo.
    if (!st.lit) {
      return;
    }
    st.leaveTimer = setTimeout(function () {
      st.leaveTimer = null;
      unlight(zone);
    }, LEAVE_LINGER_MS);
  }

  // A drag can end mid-linger. Without this the highlight stays on the page.
  function flushAll() {
    for (var i = 0; i < trackedZones.length; i++) {
      var zone = trackedZones[i];
      clearTimers(state(zone));
      unlight(zone);
    }
    trackedZones.length = 0;
  }

  function patchZoneHysteresis(zoneProto) {
    if (typeof zoneProto.dragEnterTarget !== 'function' || typeof zoneProto.dragLeaveTarget !== 'function') {
      return;
    }

    var originalEnter = zoneProto.dragEnterTarget;
    var originalLeave = zoneProto.dragLeaveTarget;

    zoneProto.dragEnterTarget = function () {
      var zone = this;
      var args = arguments;
      var cssClass = captureHighlightClass(zone, function () {
        originalEnter.apply(zone, args);
      });
      if (cssClass) {
        scheduleEnter(zone, cssClass);
      }
    };

    zoneProto.dragLeaveTarget = function () {
      var zone = this;
      var args = arguments;
      captureHighlightClass(zone, function () {
        originalLeave.apply(zone, args);
      });
      scheduleLeave(zone);
    };
  }

  // Re-anchors the shrunken chip to the pointer. Once, on drag start.
  //
  // Telerik's Draggable positions classically: each mousemove computes a delta
  // against the previous mouse position and applies it to wherever the element
  // currently is. It never recomputes an absolute position from the grab point,
  // so one correction here is preserved for the rest of the drag and the chip
  // tracks the pointer 1:1 from then on. That is why this does not need a
  // per-move handler, and why adding one would be strictly worse.
  //
  // Do not switch this to overwriting the Draggable's startPosition. That field
  // only drives the useTransformations branch, which this build does not take.
  function anchorToCursor(dock, args) {
    var domEvent = args && typeof args.get_domEvent === 'function' ? args.get_domEvent() : null;
    // jQuery-normalized events keep the native one on .originalEvent.
    var raw = domEvent && domEvent.originalEvent ? domEvent.originalEvent : domEvent;
    if (!raw) {
      return;
    }

    var pageX = raw.pageX;
    var pageY = raw.pageY;
    if (typeof pageX !== 'number' && raw.touches && raw.touches.length > 0) {
      pageX = raw.touches[0].pageX;
      pageY = raw.touches[0].pageY;
    }
    if (typeof pageX !== 'number' || typeof pageY !== 'number') {
      return;
    }

    // setLocation takes page coordinates and works out left/top against the
    // offset parent, which matters because _startDragDrop has just reparented
    // the dock to the form.
    if (!window.$telerik || typeof $telerik.setLocation !== 'function') {
      return;
    }
    $telerik.setLocation(dock.get_element(), {
      x: pageX + CURSOR_OFFSET_PX,
      y: pageY + CURSOR_OFFSET_PX
    });
  }

  function patchDockDrag(dockProto) {
    if (typeof dockProto._dragStartHandler !== 'function' || typeof dockProto._dragEndHandler !== 'function') {
      return;
    }

    var originalStart = dockProto._dragStartHandler;
    var originalEnd = dockProto._dragEndHandler;

    dockProto._dragStartHandler = function (sender, args) {
      var result = originalStart.apply(this, arguments);
      // The original returns false when the drag is refused or cancelled.
      if (result !== false) {
        document.body.classList.add(DRAGGING_CLASS);
        anchorToCursor(this, args);
      }
      return result;
    };

    dockProto._dragEndHandler = function () {
      try {
        return originalEnd.apply(this, arguments);
      } finally {
        document.body.classList.remove(DRAGGING_CLASS);
        flushAll();
      }
    };
  }

  function applyPatches() {
    if (patched || !isPageEditor()) {
      return;
    }
    var ui = window.Telerik && window.Telerik.Web && window.Telerik.Web.UI;
    if (!ui || !ui.RadDock || !ui.RadDockZone) {
      return;
    }
    patchZoneHysteresis(ui.RadDockZone.prototype);
    patchDockDrag(ui.RadDock.prototype);
    patched = true;
  }

  // RadControls load late and re-register after every WebForms partial
  // postback, so try on each load until the prototypes are actually there.
  if (window.Sys && window.Sys.Application) {
    Sys.Application.add_load(applyPatches);
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', applyPatches);
  } else {
    applyPatches();
  }
})();

Four things before you paste that in.

The rule a future edit must not break is that the structural calls inside dragEnterTarget and dragLeaveTarget, meaning _showPlaceholder and _hidePlaceholder plus the enter and leave events, stay synchronous. Drop targeting and canDrop read that state, so deferring it breaks where widgets actually land. Only the highlight class gets deferred, and captureHighlightClass is how it manages that. It shadows the instance's addCssClass and removeCssClass for the duration of the original call, while the placeholder helpers reach for Sys.UI.DomElement.addCssClass directly and are unaffected. Verify that on your own build.

Every patch is guarded. If a method goes missing after an upgrade the patch is skipped and you get default behavior rather than a broken editor. Which matters more than usual here, because these hooks came out of Object.getOwnPropertyNames(Telerik.Web.UI.RadDockZone.prototype) in the console, not out of documentation. Run that yourself if the script ever goes quiet.

Registration happens on every Sys.Application load, not once on DOM ready. RadControls load late and re-register after WebForms partial postbacks, so a single early attempt finds no prototypes and silently does nothing.

And _dragEndHandler wraps the original in try/finally. If the original throws, the body class and any lingering highlights still get cleaned up, instead of leaving the editor stuck in a permanent drag state until somebody reloads.

Matching the CSS to the script

With the timing handled in javascript, add a pair of transitions. Asymmetric on purpose:

/* Soft in, quick out. The base rule is the exit duration, because it is the
   computed style once the class comes off. A symmetric fade stacks on top of
   the 80ms leave linger and the highlight reads as hanging around after the
   cursor has gone. */
body.mytheme.sfPageEditor .sfPageContainer .RadDockZone {
  transition:
    background-color 80ms ease-out,
    border-color 80ms ease-out;
}

body.mytheme.sfPageEditor .sfPageContainer .RadDockZone.zePlaceholderHighlighted {
  transition:
    background-color 130ms ease-out,
    border-color 130ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  body.mytheme.sfPageEditor .sfPageContainer .RadDockZone {
    transition: none;
  }
}

Animating layout during a drag is normally the thing you avoid. It's safe here for one specific reason: RadDock hit-tests only from raw mouse events, so layout moving under a stationary cursor cannot re-fire the hit test. No cursor motion, no feedback loop.

Two small ones

Sitefinity's widget title bars are #f4f6f7, which assumes a white canvas. Bootstrap's default --bs-body-bg is white so out of the box you may never notice. Set it to anything else, which most real sites do, and the bars all but vanish and the editor loses its structure. Pick a color that reads as a header band against both your canvas and your cards, and keep it lighter than the widget it labels. The title bar is chrome, it should sit behind the content it names.

Fair warning, Sitefinity's border rule on those bars is six classes deep AND !important, so a short selector will silently apply your background while dropping your border. That's an odd-looking half-applied result that cost me a while to figure out.

The other one is a single rule. Every toolbox tile and every placed widget carries a blue "MVC" badge drawn as ::after content. On a busy page that's over a hundred of them, labeling an implementation detail no author will ever act on:

body.mytheme.sfPageEditor .sfMvcIcn::after {
  display: none;
}

None of this should be my job

None of it touches how the page renders for visitors, at least. Every rule is scoped behind .sfPageEditor, and the script refuses to patch anything unless it finds that class on body. Worst case is that a Sitefinity upgrade renames a hook, the selector stops matching or the guarded patch gets skipped, and you're back to the default behavior with nothing broken.

But look at what's actually in this post. A drop placeholder pre-rendered into all 35 zones on the page and shoved into flow the instant you pick anything up. A CSS rule Sitefinity wrote to undo its own padding change that can never match, because the two classes in the selector are never on an element at the same time. A drag helper stretched to the full page width, which then strands itself hundreds of pixels from your pointer the moment you correct the width, because nobody ever stored the grab point. Highlight logic with no hysteresis whatsoever, feeding back into itself through its own reflow. These aren't edge cases you'd only hit on a weird template, this is what the editor does on a stock Bootstrap 5 package on day one.

And it has done all of it, unchanged, since MVC widgets shipped. Same RadDock, same behavior, same 12px margins that don't exist on the published page. Nobody at Progress or Telerik has spent an afternoon dragging a Content Block around and asking why the page moves. I got there by monkey-patching dragEnterTarget from the outside, off class names I pulled out of Object.getOwnPropertyNames because none of it is documented, and by forcing a style flush through a getComputedStyle read to make a transition run at all. Progress owns the source. An 80ms leave linger is not a hard problem when you can just edit the control.

What actually bothers me is the reason. Progress moved on to the .NET Core renderer, so MVC gets security patches and nothing else. Fine as a roadmap decision, I guess. Except there are a very large number of MVC sites in production right now, built by people who bought the platform on the strength of MVC widgets, and the content authors who live in that editor eight hours a day are not going to get a rewritten frontend because their vendor changed direction. They just get an editor that fights them, permanently, and a partner who has decided that's finished. Maintenance mode is a decision about a codebase. Somebody still has to use the thing.

Anyway. Verify every class name against your own editor rather than trusting a blog post, this one included. These are internal Telerik class names and prototype methods. They're stable enough to build on, they're not contractual, and one wrong name is the difference between a fix and a rule that silently does nothing.