Skip to content
Document DB v7: Temporal Support Feed The Machine Here

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.Kiosk — Blazor (browser today; Blazor Server / BlazorWebView once the contracts stabilize). A kiosk-shaped Blazor add-on that also hosts a forthcoming on-screen keyboard. The docking API lives in the Shiny.Blazor.Controls.Kiosk.Docking namespace.

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

  • NuGet downloads for Shiny.Maui.Controls.Desktop
  • NuGet downloads for Shiny.Blazor.Controls.Kiosk
Frameworks
.NET MAUI
Blazor
Operating Systems
Windows
macOS
Linux
Shiny.Maui.Controls.DesktopNuGet package Shiny.Maui.Controls.Desktop
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");

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.

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" } }
};
}
}
Terminal window
dotnet add package Shiny.Blazor.Controls.Kiosk

Program.cs:

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

_Imports.razor:

@using Shiny.Blazor.Controls.Kiosk.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