Blazor Hybrid is an appealing option when you want a native Windows application without giving up Razor components, HTML, CSS, and the web UI development model. To explore that combination, I built a WPF application with four tabs: Home, Counter, Weather, and Batman Villains.
The Counter and Weather pages began with the familiar Blazor template examples. The more interesting parts are the Home page, which became a two-column Batman villains threat board, and the Batman Villains page, which provides a reorderable watch list. Both pages support drag and drop inside a WPF-hosted WebView.
This post briefly covers the hybrid infrastructure, then focuses on how the routed tabs and drag-and-drop UI work.
The Hybrid Foundation
The application is a WPF executable that hosts Razor components through BlazorWebView. There is no separate web server. WPF owns the desktop window and application lifetime, while Blazor renders the interface inside the embedded WebView2 browser control.
The host window is deliberately small:
<Grid>
<blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</Grid>
The WPF code-behind creates the service provider and registers the hybrid runtime:
var services = new ServiceCollection();
services.AddWpfBlazorWebView();
#if DEBUG
services.AddBlazorWebViewDeveloperTools();
#endif
Resources.Add(“services”, services.BuildServiceProvider());
From that point forward, the UI behaves much like a normal Blazor application. Routes.razor selects a page component, MainLayout.razor supplies the shared shell, and Bootstrap plus application CSS handle presentation.
That is the infrastructure in a nutshell: WPF supplies the native container, BlazorWebView hosts the web UI, and Razor components own the visible application experience.
Turning Routes into Desktop Tabs
The application shell uses Bootstrap’s tab styling, but each tab is a real Blazor route. This distinction matters. The tabs are not a set of hidden panels controlled by Bootstrap JavaScript. Selecting a tab asks the Blazor router to render another page component.
Here is the navigation from MainLayout.razor:
<ul class="nav nav-tabs app-tabs" role="tablist" aria-label="Main tabs">
<li class="nav-item" role="presentation">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Home</NavLink>
</li>
<li class="nav-item" role="presentation">
<NavLink class="nav-link" href="counter">Counter</NavLink>
</li>
<li class="nav-item" role="presentation">
<NavLink class="nav-link" href="weather">Weather</NavLink>
</li>
<li class="nav-item" role="presentation">
<NavLink class="nav-link" href="batman-villains">
Batman Villains
</NavLink>
</li>
</ul>
NavLink automatically adds its active class when the current route matches. Bootstrap recognizes that class and gives the selected item its tab appearance. NavLinkMatch.All is important on Home because its empty route would otherwise partially match every page.
This route-backed approach gives the desktop application several useful properties:
Each tab has its own page component and lifecycle.
The browser history inside the WebView behaves predictably.
Page state and event code stay close to the page that owns them.
Adding a tab is the same familiar process as adding a routed Blazor page.
The shared header and navigation remain stable while @Body changes below them.
The result feels like a conventional tabbed desktop utility, even though the content is rendered with Razor and CSS.
Designing the Threat Board
The Home page divides the villains into two collections: available villains and active threats. Bootstrap’s grid puts them side by side on larger windows and stacks them when the window narrows.
Each column is both a visual container and a drop target:
<div class="row g-4">
<div class="col-lg-6">
<h3>Available Villains</h3>
<span class="badge text-bg-secondary">
@availableVillains.Count
</span>
<div class="villain-drop-zone"
data-drop-zone="available">
@foreach (var villain in availableVillains)
{
<article class="villain-card"
data-villain-name="@villain.Name">
<div class="villain-name">@villain.Name</div>
<p class="villain-description">
@villain.Description
</p>
</article>
}
</div>
</div>
<div class="col-lg-6">
<h3>Active Threat List</h3>
<span class="badge text-bg-danger">
@threatVillains.Count
</span>
<div class="villain-drop-zone threat-zone"
data-drop-zone="threats">
<! - Active threat cards or the empty state →
</div>
</div>
</div>
The data-villain-name and data-drop-zone attributes form a simple contract between the rendered markup and the drag code. JavaScript does not need to understand the C# model. It only needs to identify what was dragged and where it landed.
The counts are ordinary Blazor expressions. When a villain changes collections, Blazor rerenders the cards, badges, and empty state together. JavaScript handles the physical gesture, but C# remains the source of truth.
Why Native HTML Drag and Drop Was Not Enough
The first implementation used the standard HTML5 approach: draggable=”true” plus dragstart, dragover, and drop listeners. It looked correct, but the drop never reached the component when the application ran inside the WPF WebView.
That is an important hybrid-app lesson. A feature that works in a standalone browser is not automatically reliable across an embedded browser and native host boundary. WebView2 and WPF can intercept parts of the native drag gesture before the expected browser event sequence completes.
The reliable solution was to build the interaction on pointer events:
- pointerdown records the source item and starting coordinates.
- pointermove starts a drag after a small movement threshold.
- A cloned card follows the pointer as a visual preview.
- document.elementFromPoint identifies the target under the pointer.
- pointerup completes the gesture and invokes the Blazor component.
Pointer events work for mouse, pen, and touch input through one event model. They also keep the gesture inside the browser’s ordinary pointer pipeline instead of invoking native HTML drag behavior.
A Reusable Pointer-Drag Engine
Both interactive pages use the same JavaScript helper. The helper receives a stable root element, a selector for draggable items, a selector for targets, and a callback for a successful drop.
The core setup looks like this:
function createPointerDrag(root, itemSelector, targetSelector, onDrop) {
let drag = null;
function onPointerDown(event) {
if (!event.isPrimary || event.button !== 0) {
return;
}
const item = event.target instanceof Element ? event.target.closest(itemSelector)
: null;
if (!item || !root.contains(item)) {
return;
}
drag = {
pointerId: event.pointerId,
item,
startX: event.clientX,
startY: event.clientY,
isDragging: false,
preview: null,
target: null
};
item.setPointerCapture?.(event.pointerId);
}
root.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove,
{ passive: false });
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel);
}
The item lookup uses closest, so pressing a heading or description inside a card still selects the card. The containment check prevents the helper from accidentally acting on matching elements outside its component.
The listeners are delegated from a stable root rather than attached to every card. That is particularly useful in Blazor because moving an item causes cards to be rerendered. Newly created cards automatically participate without needing their own listeners attached again.
Making a Drag Feel Like a Drag
Raw pointer coordinates are functional, but interaction feedback is what makes the UI understandable.
The helper waits until the pointer moves five pixels before starting. This threshold prevents a small hand movement during a normal click from becoming an accidental drag:
const distance = Math.hypot(
event.clientX - drag.startX,
event.clientY - drag.startY);
if (distance < 5)
{
return;
}
)
Once dragging starts, the code clones the source card and positions it next to the pointer. The clone has pointer-events: none, which is essential because hit testing must see the drop zone beneath the preview rather than the preview itself.
.drag-preview {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
pointer-events: none;
opacity: 0.92;
box-shadow: 0 18px 36px rgba(16, 24, 40, 0.28);
}
.villain-card,
.villain-reorder-item {
cursor: grab;
touch-action: none;
user-select: none;
}
The source card becomes partially transparent, valid targets receive an is-drop-target class, and the cursor changes from grab to grabbing. Those small signals answer three questions continuously: what am I moving, where will it land, and is the application responding?
Target detection is concise:
function closestFromPoint(root, x, y, selector) {
const element = document.elementFromPoint(x, y);
const match = element instanceof Element ? element.closest(selector) : null;
return match && root.contains(match) ? match : null;
}
On pointerup, the helper performs one last hit test, removes temporary classes and the preview, and calls the page-specific drop callback.
Handing the Drop Back to Blazor
The Home component creates a DotNetObjectReference after its first render and passes both that reference and the board element to JavaScript:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("hybridDragDrop.initThreatBoard",
boardElement, dotNetReference);
}
}
The JavaScript callback extracts the two data attributes and invokes the component:
(item, target) => dotNetReference.invokeMethodAsync(
"DropVillainOnBoard",
item.dataset.villainName,
target.dataset.dropZone)
The C# method performs the actual state transition:
[JSInvokable]
public void DropVillainOnBoard(string villainName,string targetContainer)
{
var villain = availableVillains
.Concat(threatVillains)
.FirstOrDefault(item => item.Name == villainName);
if (villain is null)
{
return;
}
availableVillains.Remove(villain);
threatVillains.Remove(villain);
if (targetContainer == "threats")
{
threatVillains.Add(villain);
}
else
{
availableVillains.Add(villain);
}
StateHasChanged();
}
Removing the villain from both lists before adding it to the destination keeps the transition idempotent and prevents duplicates. StateHasChanged then updates both columns and both count badges.
The component also implements IAsyncDisposable. When the user selects another tab, it asks the helper to remove its window-level listeners and disposes the .NET reference. Cleaning up interop resources is especially important in a tabbed application because users may enter and leave the same page repeatedly.
Reordering Gotham’s Watch List
The Batman Villains tab uses the same pointer engine for a different interaction. Instead of dragging a card between containers, every card is both a draggable item and a potential insertion target.
const cleanup = createPointerDrag(root, "[data-reorder-index]",
"[data-reorder-index]",
(item, target) => dotNetReference.invokeMethodAsync(
"ReorderVillain",
Number.parseInt(item.dataset.reorderIndex, 10),
Number.parseInt(target.dataset.reorderIndex, 10)));
Razor renders the current index onto each card:
@for (var index = 0; index < villains.Count; index++)
{
var itemIndex = index;
var villain = villains[itemIndex];
<article class="villain-reorder-item" data-reorder-index="@itemIndex">
<div class="villain-rank">
@((itemIndex + 1).ToString("00"))
</div>
<div class="villain-content">
<h3>@villain.Name</h3>
<p>@villain.Description</p>
</div>
<div class="drag-handle" aria-hidden="true">::</div>
</article>
}
The .NET callback validates both positions, removes the source, and inserts it at the target:
[JSInvokable]
public void ReorderVillain(int sourceIndex, int targetIndex)
{
if (sourceIndex < 0 || sourceIndex >= villains.Count ||
targetIndex < 0 || targetIndex >= villains.Count || sourceIndex == targetIndex)
{
return;
}
var draggedVillain = villains[sourceIndex];
villains.RemoveAt(sourceIndex);
villains.Insert(targetIndex, draggedVillain);
StateHasChanged();
}
There is a deceptively common off-by-one trap here. It is tempting to subtract one from targetIndex when moving an item downward because removing the source shifts later items. That produces surprising behavior when dropping the first item on the second: it gets inserted back at position zero and appears not to move.
In this UI, targetIndex means the dragged item’s final position. After removing the source, inserting directly at targetIndex gives the expected result in both directions. Dragging item 1 onto item 3 makes it item 3; dragging item 3 onto item 1 makes it item 1.
What Worked Well
This small application demonstrates a useful division of responsibility:
- WPF owns the native executable and window.
- Blazor routing turns pages into desktop-style tabs.
- Bootstrap provides a responsive layout and familiar visual structure.
- JavaScript handles high-frequency pointer movement and browser hit testing.
- C# owns the collections and application state.
- Blazor rerendering keeps counts, empty states, ranks, and cards synchronized.
The key was not forcing the entire interaction into one technology. Pointer movement belongs naturally in JavaScript, while list mutations and UI state belong naturally in the Blazor component. The interop boundary stays narrow: JavaScript reports the source and target, and C# decides what that means.
Final Thoughts
Blazor Hybrid can produce a desktop application that feels at home on Windows while retaining the component model and styling flexibility of the web stack. Route-backed tabs are a straightforward way to organize a utility-style interface, and the Batman threat board shows that richer direct-manipulation interactions are possible too.
The most reusable lesson is to test interactions inside the real hybrid host. Native HTML drag and drop looked appropriate on paper but failed at the WebView boundary. Pointer events, delegated listeners, explicit hit testing, and a small JavaScript-to-.NET callback produced a solution that was both reliable and reusable across two different drag-and-drop experiences.
That combination made the app more than a WPF window containing a web page. It became a cohesive desktop UI built from the strengths of WPF, Blazor, Bootstrap, and the browser event model.

Comments
Post a Comment