Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Docking

A Visual-Studio-style window-docking system for desktop apps. Drop a host into a page, register your panels by stable string IDs, and the layout takes care of dockable tool windows, tabbed groups, draggable splitters, auto-hide rails, and tear-off floating windows that survive across launches.

Two packages ship the same shape:

  • Shiny.Maui.Controls.Desktop — MAUI desktop (Windows + macOS AppKit + MacCatalyst + Linux GTK). Combined with Tray Icon in the same package since both share the desktop TFM matrix.
  • Shiny.Blazor.Controls — Blazor (browser today; Blazor Server / BlazorWebView once the contracts stabilize). There is no Blazor add-on package: docking ships in the main controls package, in the Shiny.Blazor.Controls.Docking namespace.

Mobile (iOS / Android) is intentionally out of scope.

  • NuGet downloads for Shiny.Maui.Controls.Desktop
  • NuGet downloads for Shiny.Blazor.Controls
Frameworks
.NET MAUI
Blazor
Operating Systems
Windows
macOS
Linux

Blazor

Default layout Output panel docked to the bottom
Default layout on Blazor Output panel docked to the bottom on Blazor
Changed files
  • MyApp/
MauiProgram.cs
MauiProgram.cs
1#if PLATFORM2using MyApp.Services;34namespace MyApp;56public static class MauiProgram7{8    public static MauiApp CreateMauiApp()9    {10        var builder = MauiApp.CreateBuilder();1112        builder    13            .UseMauiApp<App>()14            .UseShiny()15            .UseShinyDesktopControls()16            .ConfigureFonts(fonts =>17            {18                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");19                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");20            });2122        // TODO: once you have localization, add these23        //builder.Services.AddLocalization();24        //builder.Services.AddStronglyTypedLocalizations();25        builder.Services.AddSingleton(TimeProvider.System);26#if DEBUG27        builder.Logging.SetMinimumLevel(LogLevel.Trace);28        builder.Logging.AddDebug();29#endif3031        // this is source generated by Shiny.Extensions.DependencyInjection - simply add [Singleton], [Scoped], or [Transient] to your classes32        //builder.Services.AddGeneratedServices();33        // builder.Services.AddGeneratedAITools(); // https://shinylib.net/di/ai-tools/34        var app = builder.Build();3536        return app;37    }38}39#endif
Terminal window
dotnet add package Shiny.Maui.Controls.Desktop

In MauiProgram.cs:

using Shiny;
using MyApp.Panels; // SolutionExplorerPanel, OutputPanel — your dockable views
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseShinyDocking()
.AddDockPanel<SolutionExplorerPanel>("solution-explorer", displayName: "Explorer", icon: "📁")
.AddDockPanel<OutputPanel>("output");

AddShinyControls() registers docking too, and takes the panels inline: AddShinyControls(cfg => cfg.AddDockPanel<OutputPanel>("output")). Both are TryAdd, so calling either or both is safe.

Each AddDockPanel<TView>("id") call registers TView as a transient service and an IDockableContentFactory keyed to your stable string ID. Persisted layouts reference panels by that ID, so you can rename the C# class without breaking saved layouts. The optional displayName sets the tab title (defaults to the panel ID) and icon sets an emoji / unicode glyph shown on tabs and collapsed edge bars; a panel that implements IDockableContent overrides both per-instance.

Some panels a surface simply cannot work without — a file explorer’s folder tree, an editor’s document area. Closing one of those leaves a layout with nothing on screen that would bring it back, because nothing reopens a panel unless you have built that yourself. Register those with canClose: false:

services
.AddShinyDocking()
.AddDockPanel<FolderTreePanel>("explorer-tree", displayName: "Folders", icon: "📁", canClose: false)
.AddDockPanel<FileListPanel>("explorer-files", displayName: "Files", icon: "📄", canClose: false)
.AddDockPanel<OutputPanel>("output"); // closable, as most panels should be

The tab stops drawing a close button, and HidePanelAsync refuses the panel as well — so it is a rule the host keeps rather than a button you happened to hide. Prefer it to suppressing .shiny-dock-tab__close in CSS, which only removes the affordance and leaves every other close path open.

Attach the host inside any existing ContentPageDockHostView is a ContentView, not a ContentPage subclass. You keep full control of your Shell / page architecture:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:docking="clr-namespace:Shiny.Maui.Controls.Desktop.Docking;assembly=Shiny.Maui.Controls.Desktop"
x:Class="MyApp.DockingShellPage"
Title="IDE">
<docking:DockHostView x:Name="Dock"
InitialLayout="{Binding StartupLayout}"
LayoutStore="{Binding LayoutStore}"
IsLocked="{Binding IsLayoutLocked}" />
</ContentPage>

DockHostView implements IDockHost directly — call Dock.ShowPanelAsync(...) / Dock.ResetLayoutAsync() and subscribe to Dock.Events from code-behind. Bindable properties: InitialLayout (DockRoot? — the layout built when no persisted layout loads), LayoutStore (IDockLayoutStore? — loaded at startup, auto-saved with debouncing on every layout change), and IsLocked (bool).

A dockable panel is just a regular View:

public sealed class SolutionExplorerPanel : ContentView
{
public SolutionExplorerPanel(/* inject anything you need */)
{
Content = new VerticalStackLayout
{
Padding = 12,
Children = { new Label { Text = "Solution Explorer" } }
};
}
}

Program.cs:

using Shiny.Blazor.Controls.Docking;
builder.Services
.AddShinyDocking()
.AddDockPanel<SolutionExplorerPanel>("solution-explorer", displayName: "Explorer", icon: "📁")
.AddDockPanel<OutputPanel>("output");

_Imports.razor:

@using Shiny.Blazor.Controls.Docking

Any razor page:

<DockHost @ref="host"
InitialLayout="@layout"
LayoutStore="@layoutStore"
IsLocked="@locked" />

Component parameters: InitialLayout (DockRoot?), IsLocked (bool), LayoutStore (IDockLayoutStore? — loaded at startup, auto-saved with debouncing), and BackgroundColor (CSS color override). The component implements IDockHost — capture it with @ref to call ShowPanelAsync / ResetLayoutAsync / Snapshot and subscribe to Events (e.g. in OnAfterRender).

Each panel is a regular Razor component. Blazor docking supports in-app floating; popping panels out into separate browser windows is not supported (Blazor runtime instances cannot share component instances across windows).

readonly DockRoot layout = new()
{
MainWindow = new DockWindowState
{
LeftRail = new DockGroup { Tabs = { new DockTab { PanelTypeId = "solution-explorer" } } },
DocumentArea = new DockSplit
{
Orientation = DockOrientation.Vertical,
Ratio = 0.7,
First = new DockGroup
{
Tabs =
{
new DockTab { PanelTypeId = "editor" },
new DockTab { PanelTypeId = "readme" }
}
},
Second = new DockGroup { Tabs = { new DockTab { PanelTypeId = "output" } } }
}
}
};

A POCO tree with a source-generated System.Text.Json context (AOT-safe). Round-trip with DockSerialization.Serialize(root) and DockSerialization.Deserialize(json).

DockRoot
├── int SchemaVersion + int MinReadableVersion
├── DockWindowState MainWindow
└── List<DockWindowState> FloatingWindows (order = z-order)
DockWindowState
├── DockRect? Bounds (screen-coord rectangle — desktop only)
├── bool IsMaximized + bool IsFullScreen
├── DockNode DocumentArea (structurally distinct document well)
├── DockNode? LeftRail + TopRail + RightRail + BottomRail
├── double? LeftRailSize + TopRailSize + RightRailSize + BottomRailSize
├── List<DockArea> CollapsedRails (legacy whole-rail collapse — converted to per-panel on load)
├── List<DockCollapsedPanel> CollapsedTabs (per-panel edge-bar collapse: which panel, which edge)
└── string? ActivePanelId (for focus restoration on load)
DockNode = DockSplit | DockGroup | DockEmpty (polymorphic, $kind discriminator)
DockSplit { Orientation, Ratio (0..1), First, Second }
DockGroup { GroupId, List<DockTab> Tabs, int ActiveTabIndex, List<int> FocusHistory (MRU), bool IsCollapsed }
DockEmpty { }
DockTab { PanelTypeId, PanelInstanceId, bool IsPinned }
DockCollapsedPanel { DockArea Area, DockTab Tab }

Schema versioning is built in from day one: bump SchemaVersion, register an IDockLayoutMigrator for each step, and stored layouts migrate forward as your app upgrades. Unknown PanelTypeIds on load land in a missing-panels tray rather than silently dropping — so a removed feature gracefully degrades instead of corrupting the layout.

Per-window controller exposed by DockHostView / <DockHost />.

public interface IDockHost
{
bool IsLocked { get; set; } // layout read-only mode (kiosk / demos)
IDockEvents Events { get; }
IDockCommandScope CommandScope { get; }
Task LoadAsync(DockRoot root, CancellationToken ct = default);
DockRoot Snapshot();
Task ShowPanelAsync(string panelTypeId, DockArea preferredArea = DockArea.Left, CancellationToken ct = default);
Task HidePanelAsync(string panelInstanceId, CancellationToken ct = default);
Task ActivatePanelAsync(string panelInstanceId, CancellationToken ct = default);
Task ResetLayoutAsync(CancellationToken ct = default);
Task SetRailCollapsedAsync(DockArea area, bool collapsed, CancellationToken ct = default);
}

Registered by AddDockPanel<T>("id") — resolves the stable string ID to a View (MAUI) or RenderFragment (Blazor).

public interface IDockableContentFactory
{
string PanelTypeId { get; }
string DisplayName => PanelTypeId; // tab title (default interface member)
string? Icon => null; // optional emoji / unicode glyph
Task<View> CreateAsync(string instanceId, CancellationToken ct = default);
}

Optional interface your panel content can implement to participate in disposal, focus, and pointer hand-off semantics.

public interface IDockableContent
{
string Title { get; }
object? Icon { get; }
bool CanClose { get; }
bool CanFloat { get; }
void OnActivated();
void OnDeactivated();
// Embedded editors return true to claim pointer-down events — prevents the dock
// system from starting a tab drag during caret-drag inside a text editor.
bool WantsPointerDown(double x, double y);
}

Persistence is consumer-supplied — no default implementation ships. Hand your store to the host (LayoutStore bindable property on DockHostView, LayoutStore parameter on <DockHost>): it loads at startup (falling back to InitialLayout when the store returns null) and auto-saves every layout change, debounced by SaveDebounceMs. Two minimal patterns:

// File-backed
public sealed class FileDockLayoutStore(string path) : IDockLayoutStore
{
public int SaveDebounceMs => 500;
public async Task<DockRoot?> LoadAsync(CancellationToken ct = default)
=> File.Exists(path) ? DockSerialization.Deserialize(await File.ReadAllTextAsync(path, ct)) : null;
public Task SaveAsync(DockRoot root, CancellationToken ct = default)
=> File.WriteAllTextAsync(path, DockSerialization.Serialize(root), ct);
}
// Shiny.Stores-backed (works on every host)
public sealed class ShinyStoreDockLayoutStore(IKeyValueStore store) : IDockLayoutStore
{
public int SaveDebounceMs => 500;
public Task<DockRoot?> LoadAsync(CancellationToken ct = default)
=> Task.FromResult(store.Get<string?>("docking.layout") is { } json
? DockSerialization.Deserialize(json) : null);
public Task SaveAsync(DockRoot root, CancellationToken ct = default)
{
store.Set("docking.layout", DockSerialization.Serialize(root));
return Task.CompletedTask;
}
}

Forward-only schema migrations. Register one per version step — chained automatically.

public interface IDockLayoutMigrator
{
int FromVersion { get; }
int ToVersion { get; }
DockRoot Migrate(DockRoot input);
}

Observability surface — wire into telemetry, undo stacks, or autosave debouncing.

public interface IDockEvents
{
event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
event EventHandler<PanelActivatedEventArgs>? PanelActivated;
event EventHandler<DockDragEventArgs>? DragStarted;
event EventHandler<DockDragEventArgs>? DragCompleted;
event EventHandler<DockDragEventArgs>? DragCancelled;
}

Scopes global accelerators (Ctrl+W close tab, Ctrl+Tab MRU, Ctrl+Alt+PgUp/Dn group navigation) to the dock surface so they only fire when keyboard focus is inside it.

public interface IDockCommandScope
{
bool IsInScope { get; }
string? ActiveGroupId { get; }
string? ActivePanelInstanceId { get; }
}

Everything below ships working end-to-end on both hosts:

  • Tab drag — drop on another group’s center to merge, on a group edge (left/right/top/bottom) to split, within the tab strip to reorder, or outside the host to tear off a floating window. Drop zones render as a colored overlay while dragging.
  • Floating windows — independent dockable windows with their own bounds, persisted in DockRoot.FloatingWindows. Move via the header, resize via the corner grip, re-dock with the ⇤ button, close with ×.
  • Splitters — drag to resize; DockSplit.Ratio persists and is clamped to 0.08–0.92 so neither side can vanish.
  • Per-panel collapse — collapse a tab to a slim edge bar (icon + rotated title); click to restore. SetRailCollapsedAsync(area, collapsed) collapses or restores a whole rail at once. Collapsed state persists via CollapsedTabs.
  • Locked modeIsLocked = true disables drag, resize, collapse, and close; switching between existing tabs still works. Ideal for kiosk and demo scenarios.
  • Events — wire host.Events.LayoutChanged / PanelActivated / DragStarted / DragCompleted / DragCancelled for telemetry, autosave hooks, or undo stacks.

Every tab in the layout has a unique PanelInstanceId (GUID). The host creates content once per instance via the factory and keeps the same view/component instance alive as it moves between groups, rails, and floating windows.

When a panel is closed, the host disposes the content if it implements IDisposable. Tearing off to a floating window or moving between groups in the same window does not dispose — the same view instance is moved. This is intentional and documented because retrofitting “recreated” → “preserved” would break every panel implementation, and consumers will rely on whichever behavior ships first.

MAUI: ResourceDictionary keys (DockTabBackgroundBrush, DockGroupSeparatorBrush, …) — names locked before styling lands so consumer code stays stable.

Blazor: CSS custom properties (--shiny-dock-host-bg, --shiny-dock-placeholder-fg, …) — override on a parent or on <DockHost> itself.

Animation duration is a theme token, not a constant — reduced-motion / accessibility consumers can flatten to zero without recompiling.

  • macOS tear-off uses native AppKit (net10.0-macos) NSPanel at NSWindowLevel.Floating. MacCatalyst stays in-app-only in v1 (Catalyst can’t do borderless click-through windows for the drag ghost).
  • Cross-window drag uses mouse-down implicit capture, not global mouse hooks. This sidesteps the macOS Accessibility entitlement entirely, so the library works under sandboxed / Mac App Store distribution.
  • Floating window z-order is persisted as part of the schema — the floating-windows list is ordered.
  • Document area vs tool area is a schema-level partition rather than a per-panel flag, so document-well focus rules and tab behavior don’t leak into every consumer.
  • Tray Icon — ships in the same Shiny.Maui.Controls.Desktop package
  • FloatingPanel — a mobile-shaped bottom/top sheet for MAUI; distinct from desktop docking auto-hide rails