Sam Afshari's Notes
  • GitHub
  • Twitter/X
  • All posts
  • Ed
  • Generals64
  • NuGets
  • POIWorld

Docking a C# app to a screen edge with the Windows AppBar API - Fri, Apr 17, 2026

The Windows taskbar is an AppBar: a window that sits glued to a screen edge and tells the rest of the desktop, “this strip is mine, don’t maximize over me.”

Any app can do the same thing using a single shell API: SHAppBarMessage. This post walks through a minimal C# example that registers itself, docks to any of the four edges, and cleanly tears down.

The full sample is ~120 lines of code split between the AppBar wrapper and a tiny UI. The wrapper has zero dependencies beyond user32 and shell32, so the same code drops into WinForms, WPF, MAUI, Unity, or any other host with only the HWND-fetch line changing.

What an AppBar actually is

Two things the OS does for an AppBar that it doesn’t do for a regular borderless window:

  1. Reserves space in the work area:

    When the user maximizes Notepad, Windows uses SystemParametersInfo(SPI_GETWORKAREA, ...) to know where the desktop ends. An AppBar shrinks that work area for everyone else.

  2. Negotiates with other AppBars:

    If the taskbar is on the bottom and your app docks to the bottom too, the shell shifts your rect upward so you don’t overlap.

Without registration, you can absolutely move a borderless topmost window to (0, screenHeight - 80, screenWidth, 80). It just won’t stop other windows from drawing under it, and a maximized app will cover it. The toggle in the example shows the difference: dock with the toggle off and watch a maximized window cover the dock; toggle it on and watch the maximized window snap up to make room.

The four shell messages you need

Everything goes through one function:

[DllImport("shell32.dll")]
private static extern uint SHAppBarMessage(uint dwMessage, ref APPBARDATA pData);

The dwMessage selects one of multiple operations. For a basic dock you only need four:

Message What it does
ABM_NEW (0x00) Register the HWND as an AppBar. Required before any positioning calls.
ABM_QUERYPOS (0x02) “If I want this rect, what would actually fit?” Shell adjusts it to avoid overlap with other AppBars.
ABM_SETPOS (0x03) Commit the rect. The work area shrinks now.
ABM_REMOVE (0x01) Unregister. Skip this and Windows will leak a permanent reservation until reboot.

The data passed in is a fixed-layout struct:

[StructLayout(LayoutKind.Sequential)]
private struct APPBARDATA
{
    public int    cbSize;            // must be set to Marshal.SizeOf<APPBARDATA>()
    public IntPtr hWnd;              // the window being registered/positioned
    public uint   uCallbackMessage;  // optional — message id sent on shell events
    public uint   uEdge;             // ABE_LEFT/TOP/RIGHT/BOTTOM (0..3)
    public RECT   rc;                // requested or returned rect
    public IntPtr lParam;            // unused for our messages
}

cbSize is non-negotiable. The shell uses it to version the struct; pass the wrong value and the call silently no-ops.

Register and unregister

These are the two simplest calls — one field beyond cbSize:

public static void Register(IntPtr hwnd)
{
    var data = new APPBARDATA { cbSize = Marshal.SizeOf<APPBARDATA>(), hWnd = hwnd };
    SHAppBarMessage(ABM_NEW, ref data);
}

public static void Unregister(IntPtr hwnd)
{
    var data = new APPBARDATA { cbSize = Marshal.SizeOf<APPBARDATA>(), hWnd = hwnd };
    SHAppBarMessage(ABM_REMOVE, ref data);
}

Both are idempotent in practice: calling ABM_NEW twice on the same HWND just returns FALSE the second time, and ABM_REMOVE on an unregistered HWND is a no-op.

The contract worth remembering: always pair them. Hook Window.Closing and call Unregister there. If your process crashes between Register and Unregister, the shell usually notices the dead HWND and reclaims the slot, but I’ve seen cases where a stale reservation persists until logout.

Position with the negotiate-then-commit pattern

Setting the dock position is a two-step dance:

public static void SetPosition(IntPtr hwnd, Edge edge, int thickness, bool reserveSpace)
{
    int sw = GetSystemMetrics(SM_CXSCREEN);
    int sh = GetSystemMetrics(SM_CYSCREEN);
    var rect = edge switch
    {
        Edge.Left   => new RECT(0,             0,             thickness, sh),
        Edge.Top    => new RECT(0,             0,             sw,        thickness),
        Edge.Right  => new RECT(sw - thickness, 0,            sw,        sh),
        Edge.Bottom => new RECT(0,             sh - thickness, sw,       sh),
    };

    if (reserveSpace)
    {
        var data = new APPBARDATA
        {
            cbSize = Marshal.SizeOf<APPBARDATA>(),
            hWnd   = hwnd,
            uEdge  = (uint)edge,
            rc     = rect
        };

        SHAppBarMessage(ABM_QUERYPOS, ref data);

        // QUERYPOS only shifts the perpendicular axis to avoid overlap.
        // We need to re-apply our requested thickness afterwards.
        switch (edge)
        {
            case Edge.Left:   data.rc.Right  = data.rc.Left + thickness;   break;
            case Edge.Top:    data.rc.Bottom = data.rc.Top + thickness;    break;
            case Edge.Right:  data.rc.Left   = data.rc.Right - thickness;  break;
            case Edge.Bottom: data.rc.Top    = data.rc.Bottom - thickness; break;
        }

        SHAppBarMessage(ABM_SETPOS, ref data);
        rect = data.rc;
    }

    MoveWindow(hwnd, rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top, true);
}

Two subtleties worth calling out:

Pass full-screen coordinates, not work-area coordinates. The work area already accounts for existing AppBars. If you compute your rect against the work area and then ask for reservation, you’ll get pushed in twice: once by your math, once by ABM_QUERYPOS.

ABM_QUERYPOS only adjusts the perpendicular axis. If you ask for the bottom edge with Top = sh - 80, and the taskbar is also on the bottom occupying 48 pixels, the shell returns a rect with Top = sh - 48 - 80 (it slid you up to make room) but it also clamps Bottom to wherever it slid the top to. You have to re-apply your requested thickness afterwards or your bar collapses to zero height. This is the bug that ate an evening of mine and isn’t documented anywhere obvious.

Hooking it up to a real app

The sample app is a tiny WebView2 browser: address bar, Go, and a ⋯ button that drops a menu with the window-management controls. Putting the dock controls behind a menu keeps the top bar usable even when the window is docked to a narrow vertical strip.

Getting the HWND out of a WinForms Form is just this.Handle. The wiring is unremarkable:

public sealed class MainForm : Form
{
    private AppBar.Edge? _currentEdge;

    public MainForm()
    {
        // …build UI: address TextBox, Go button, ⋯ button, WebView2…
        _menu.Click += (_, _) => _dropdown.Show(_menu, new Point(0, _menu.Height));
        FormClosing += (_, _) => AppBar.Unregister(Handle);
    }

    private void DockTo(AppBar.Edge edge)
    {
        _currentEdge = edge;
        FormBorderStyle = FormBorderStyle.None;
        bool vertical = edge is AppBar.Edge.Left or AppBar.Edge.Right;
        int thickness = vertical ? 420 : 200;
        AppBar.SetPosition(Handle, edge, thickness, ReserveSpace);
    }

    private void Undock()
    {
        _currentEdge = null;
        AppBar.Unregister(Handle);
        _reserveItem.Checked = false;
        FormBorderStyle = FormBorderStyle.Sizable;
        AppBar.RestoreFloating(Handle, 1000, 680);
    }

    private void OnReserveChanged()
    {
        if (ReserveSpace) AppBar.Register(Handle);
        else AppBar.Unregister(Handle);
        if (_currentEdge is { } edge) DockTo(edge);          // re-apply with new mode
    }
}

The toggle keeps registration orthogonal to position: toggle the AppBar on and off without losing your dock. This is closer to how the taskbar’s auto-hide setting works than a one-shot “dock here as an AppBar” API.

Thickness is asymmetric per orientation because the content is asymmetric: a 200-pixel horizontal strip is a reasonable browser letterbox, but the same 200 pixels docked to the left is too narrow for the address bar to breathe. Use a wider thickness for left/right docks (420 px works for a typical sidebar browser) and a thinner one for top/bottom (200 px). The exact numbers are up to your UI, the AppBar API itself doesn’t care.

Nothing here is WinForms-specific. Swap Form.Handle for Avalonia.Window.TryGetPlatformHandle()?.Handle, Hwnd-from-WPF’s WindowInteropHelper, or Game.Window.Handle from MonoGame and the same AppBar wrapper works verbatim.

What this example deliberately leaves out

The lines above will dock cleanly and let go cleanly. Three things production code typically adds:

A uCallbackMessage and a WndProc subclass. The shell sends notifications when other AppBars change, the work area resizes, or the user activates a full-screen app. To receive them you set uCallbackMessage to a value from RegisterWindowMessage("YourApp.AppBar") before ABM_NEW, then subclass the window proc with SetWindowLongPtr(GWLP_WNDPROC, ...) and re-assert your position on ABN_POSCHANGED. It matters if the user runs other AppBar apps alongside yours.

Drift detection. Win+Shift+→ will move your window to the next monitor and the OS won’t tell you. A periodic check that compares the actual GetWindowRect against your expected rect, and re-docks when they diverge, fixes this. Same for monitor reconnects.

Multi-monitor. GetSystemMetrics(SM_CXSCREEN) returns the primary monitor only. For multi-mon, use MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) followed by GetMonitorInfo, and feed the per-monitor rect into the same code path. The AppBar protocol itself is per-monitor, so the rest just works.

Try it

cd AppBarExample
dotnet run
  • Click a dock button with the toggle off → window glues to the edge as a borderless strip, but maximized windows cover it.
  • Toggle on → maximized windows now snap to fit around your strip.
  • Click another edge → moves and re-reserves.
  • Click Undock → unregister, restore the title bar, recenter.

Back to Home


© Sam Afshari 2024