Implementing a Custom Window Title Bar in WPF

There are several good reasons for wanting custom window chrome in WPF, such as fitting in additional UI or implementing a Dark theme. However the actual implementation is kind of tricky, since it is now your job to provide a bunch of features that you used to get for free. I’ll walk you through my implementation.

Appearance

I chose to emulate the Windows 10 style.

Windows 10 standard title bar UI

This will keep my UI consistent with the rest of Windows. I am choosing not to attempt to match the style for old versions of Windows, as I don’t think that would be a great time investment. It’s one little thing you lose when going with a full custom title bar, but it should be worth it by allowing full cohesive theming. Buttons are 46 wide, 32 tall.

Building the UI

First, set WindowStyle=”None” on your Window. That will remove the built-in title bar and allow you to do everything on your own.

Next, allocate space for your title bar in your UI. I like to allocate a Grid row for it.

Add this under the <Window> node in your XAML:

<WindowChrome.WindowChrome>
    <WindowChrome CaptionHeight="32" ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
</WindowChrome.WindowChrome>

The CaptionHeight tells the OS to treat the top 32px of your window as if it was a title bar. This means that click to drag works, along with double clicking to maximize/restore, shaking to minimize other windows, etc. The ResizeBorderThickness allows the standard window resize logic to work, so we don’t need to reimplement that either.

Now we need to make the actual UI. This is mine:

<Grid>
	<Grid.ColumnDefinitions>
		<ColumnDefinition Width="Auto" />
		<ColumnDefinition Width="*" />
		<ColumnDefinition Width="Auto" />
		<ColumnDefinition Width="Auto" />
		<ColumnDefinition Width="Auto" />
	</Grid.ColumnDefinitions>
	<Image
		Grid.Column="0"
		Width="22"
		Height="22"
		Margin="4"
		Source="/Icons/VidCoder32.png" />
	<TextBlock
		Grid.Column="1"
		Margin="4 0 0 0"
		VerticalAlignment="Center"
		FontSize="14"
		Text="{Binding WindowTitle}">
		<TextBlock.Style>
			<Style TargetType="TextBlock">
				<Style.Triggers>
					<DataTrigger Binding="{Binding IsActive, RelativeSource={RelativeSource AncestorType=Window}}" Value="False">
						<Setter Property="Foreground" Value="{DynamicResource WindowTitleBarInactiveText}" />
					</DataTrigger>
				</Style.Triggers>
			</Style>
		</TextBlock.Style>
	</TextBlock>

	<Button
		Grid.Column="2"
		Click="OnMinimizeButtonClick"
		RenderOptions.EdgeMode="Aliased"
		Style="{StaticResource TitleBarButtonStyle}">
		<Path
			Width="46"
			Height="32"
			Data="M 18,15 H 28"
			Stroke="{Binding Path=Foreground,
							 RelativeSource={RelativeSource AncestorType={x:Type Button}}}"
			StrokeThickness="1" />
	</Button>
	<Button
		Name="maximizeButton"
		Grid.Column="3"
		Click="OnMaximizeRestoreButtonClick"
		Style="{StaticResource TitleBarButtonStyle}">
		<Path
			Width="46"
			Height="32"
			Data="M 18.5,10.5 H 27.5 V 19.5 H 18.5 Z"
			Stroke="{Binding Path=Foreground,
							 RelativeSource={RelativeSource AncestorType={x:Type Button}}}"
			StrokeThickness="1" />
	</Button>
	<Button
		Name="restoreButton"
		Grid.Column="3"
		Click="OnMaximizeRestoreButtonClick"
		Style="{StaticResource TitleBarButtonStyle}">
		<Path
			Width="46"
			Height="32"
			Data="M 18.5,12.5 H 25.5 V 19.5 H 18.5 Z M 20.5,12.5 V 10.5 H 27.5 V 17.5 H 25.5"
			Stroke="{Binding Path=Foreground,
							 RelativeSource={RelativeSource AncestorType={x:Type Button}}}"
			StrokeThickness="1" />
	</Button>
	<Button
		Grid.Column="4"
		Click="OnCloseButtonClick"
		Style="{StaticResource TitleBarCloseButtonStyle}">
		<Path
			Width="46"
			Height="32"
			Data="M 18,11 27,20 M 18,20 27,11"
			Stroke="{Binding Path=Foreground,
							 RelativeSource={RelativeSource AncestorType={x:Type Button}}}"
			StrokeThickness="1" />
	</Button>
</Grid>

I’ve got the app icon. I chose not to implement the special drop-down menu that comes with the standard title bar since it’s not often used and other major apps like Visual Studio Code don’t bother with it. But it’s certainly something you could add.

The title text has a trigger to change its color based on the “Active” state of the window. That allows the user to better tell if the window has focus or not.

The actual buttons use TitleBarButtonStyle and TitleBarCloseButtonStyle:

<Style x:Key="TitleBarButtonStyle" TargetType="Button">
	<Setter Property="Foreground" Value="{DynamicResource WindowTextBrush}" />
	<Setter Property="Padding" Value="0" />
	<Setter Property="WindowChrome.IsHitTestVisibleInChrome" Value="True" />
	<Setter Property="IsTabStop" Value="False" />
	<Setter Property="Template">
		<Setter.Value>
			<ControlTemplate TargetType="{x:Type Button}">
				<Border
					x:Name="border"
					Background="Transparent"
					BorderThickness="0"
					SnapsToDevicePixels="true">
					<ContentPresenter
						x:Name="contentPresenter"
						Margin="0"
						HorizontalAlignment="Center"
						VerticalAlignment="Center"
						Focusable="False"
						RecognizesAccessKey="True" />
				</Border>
				<ControlTemplate.Triggers>
					<Trigger Property="IsMouseOver" Value="true">
						<Setter TargetName="border" Property="Background" Value="{DynamicResource MouseOverOverlayBackgroundBrush}" />
					</Trigger>
					<Trigger Property="IsPressed" Value="true">
						<Setter TargetName="border" Property="Background" Value="{DynamicResource PressedOverlayBackgroundBrush}" />
					</Trigger>
				</ControlTemplate.Triggers>
			</ControlTemplate>
		</Setter.Value>
	</Setter>
</Style>

<Style x:Key="TitleBarCloseButtonStyle" TargetType="Button">
	<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" />
	<Setter Property="Padding" Value="0" />
	<Setter Property="WindowChrome.IsHitTestVisibleInChrome" Value="True" />
	<Setter Property="IsTabStop" Value="False" />
	<Setter Property="Template">
		<Setter.Value>
			<ControlTemplate TargetType="{x:Type Button}">
				<Border
					x:Name="border"
					Background="Transparent"
					BorderThickness="0"
					SnapsToDevicePixels="true">
					<ContentPresenter
						x:Name="contentPresenter"
						Margin="0"
						HorizontalAlignment="Center"
						VerticalAlignment="Center"
						Focusable="False"
						RecognizesAccessKey="True" />
				</Border>
				<ControlTemplate.Triggers>
					<Trigger Property="IsMouseOver" Value="true">
						<Setter TargetName="border" Property="Background" Value="{DynamicResource MouseOverWindowCloseButtonBackgroundBrush}" />
						<Setter Property="Foreground" Value="{DynamicResource MouseOverWindowCloseButtonForegroundBrush}" />
					</Trigger>
					<Trigger Property="IsPressed" Value="true">
						<Setter TargetName="border" Property="Background" Value="{DynamicResource PressedWindowCloseButtonBackgroundBrush}" />
						<Setter Property="Foreground" Value="{DynamicResource MouseOverWindowCloseButtonForegroundBrush}" />
					</Trigger>
				</ControlTemplate.Triggers>
			</ControlTemplate>
		</Setter.Value>
	</Setter>
</Style>

These are buttons with stripped-down control templates to remove a lot of the extra gunk. They have triggers to change the background color on mouse over (and the foreground color in the case of the Close button). Also they set WindowChrome.IsHitTestVisibleInChrome to True, which allows them to pick up clicks even though they are in the 32px caption area we set up earlier.

The button content itself uses <Path> to draw the icons. The minimize button uses RenderOptions.EdgeMode=”Aliased” to disable anti-aliasing and make sure it renders crisply without blurring over into other pixels. I set the Stroke to pick up the Foreground color from the parent button. The Path data for the maximize/restore buttons are all set on .5 to make sure it renders cleanly at the standard 96 DPI. With whole numbers it ends up drawing on the edge of the pixel and blurring the lines. We can’t use the same “Aliased” trick here as that might cause the pixel count for different lines to change and look off at different zoom levels like 125%/150%.

Looking good!

Responding to button clicks

Now that we have the UI in place, we need to respond to those button clicks. I normally use databinding/MVVM, but in this case I decided to bypass the viewmodel since these are really talking directly to the window.

Event handler methods:

private void OnMinimizeButtonClick(object sender, RoutedEventArgs e)
{
	this.WindowState = WindowState.Minimized;
}

private void OnMaximizeRestoreButtonClick(object sender, RoutedEventArgs e)
{
	if (this.WindowState == WindowState.Maximized)
	{
		this.WindowState = WindowState.Normal;
	}
	else
	{
		this.WindowState = WindowState.Maximized;
	}
}

private void OnCloseButtonClick(object sender, RoutedEventArgs e)
{
	this.Close();
}

Helper method to refresh the maximize/restore button:

private void RefreshMaximizeRestoreButton()
{
	if (this.WindowState == WindowState.Maximized)
	{
		this.maximizeButton.Visibility = Visibility.Collapsed;
		this.restoreButton.Visibility = Visibility.Visible;
	}
	else
	{
		this.maximizeButton.Visibility = Visibility.Visible;
		this.restoreButton.Visibility = Visibility.Collapsed;
	}
}

This, we call in the constructor and in an event handler for Window.StateChanged:

private void Window_StateChanged(object sender, EventArgs e)
{
	this.RefreshMaximizeRestoreButton();
}

This will make sure the button displays correctly no matter how the window state change is invoked.

Maximized placement

You thought we were done? Hah. Windows has other plans. You might notice that when you maximize your window, some content is getting cut off and it’s hiding your task bar. The default values it picks for maximized window placement are really weird, where it cuts off 7px of your window content and doesn’t account for task bar placement.

To fix this, we need to listen for the WM_GETMINMAXINFO WndProc message to tell our window it needs to go somewhere different when maximize. Put this in your window codebehind:

protected override void OnSourceInitialized(EventArgs e)
{
	base.OnSourceInitialized(e);
	((HwndSource)PresentationSource.FromVisual(this)).AddHook(HookProc);
}

public static IntPtr HookProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
	if (msg == WM_GETMINMAXINFO)
	{
		// We need to tell the system what our size should be when maximized. Otherwise it will cover the whole screen,
		// including the task bar.
		MINMAXINFO mmi = (MINMAXINFO)Marshal.PtrToStructure(lParam, typeof(MINMAXINFO));

		// Adjust the maximized size and position to fit the work area of the correct monitor
		IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);

		if (monitor != IntPtr.Zero)
		{
			MONITORINFO monitorInfo = new MONITORINFO();
			monitorInfo.cbSize = Marshal.SizeOf(typeof(MONITORINFO));
			GetMonitorInfo(monitor, ref monitorInfo);
			RECT rcWorkArea = monitorInfo.rcWork;
			RECT rcMonitorArea = monitorInfo.rcMonitor;
			mmi.ptMaxPosition.X = Math.Abs(rcWorkArea.Left - rcMonitorArea.Left);
			mmi.ptMaxPosition.Y = Math.Abs(rcWorkArea.Top - rcMonitorArea.Top);
			mmi.ptMaxSize.X = Math.Abs(rcWorkArea.Right - rcWorkArea.Left);
			mmi.ptMaxSize.Y = Math.Abs(rcWorkArea.Bottom - rcWorkArea.Top);
		}

		Marshal.StructureToPtr(mmi, lParam, true);
	}

	return IntPtr.Zero;
}

private const int WM_GETMINMAXINFO = 0x0024;

private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;

[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);

[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);

[Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
	public int Left;
	public int Top;
	public int Right;
	public int Bottom;

	public RECT(int left, int top, int right, int bottom)
	{
		this.Left = left;
		this.Top = top;
		this.Right = right;
		this.Bottom = bottom;
	}
}

[StructLayout(LayoutKind.Sequential)]
public struct MONITORINFO
{
	public int cbSize;
	public RECT rcMonitor;
	public RECT rcWork;
	public uint dwFlags;
}

[Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
	public int X;
	public int Y;

	public POINT(int x, int y)
	{
		this.X = x;
		this.Y = y;
	}
}

[StructLayout(LayoutKind.Sequential)]
public struct MINMAXINFO
{
	public POINT ptReserved;
	public POINT ptMaxSize;
	public POINT ptMaxPosition;
	public POINT ptMinTrackSize;
	public POINT ptMaxTrackSize;
}

When the system asks the window where it should be when it maximizes, this code will ask what monitor it’s on, then place itself in the work area of the monitor (not overlapping the task bar).

Window border

Finally, the window can be kind of hard to pick out when it doesn’t have a border, if it’s put against the wrong background. Let’s fix that now. Wrap your window UI in this:

<Border Style="{StaticResource WindowMainPanelStyle}">
    ...your UI
</Border>

This is the style:

<Style x:Key="WindowMainPanelStyle" TargetType="{x:Type Border}">
    <Setter Property="BorderBrush" Value="{DynamicResource WindowBorderBrush}" />
    <Setter Property="BorderThickness" Value="1" />
    <Style.Triggers>
        <DataTrigger Binding="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}, Path=WindowState}" Value="Maximized">
            <Setter Property="BorderThickness" Value="0" />
        </DataTrigger>
    </Style.Triggers>
</Style>

This will remove the 1px border when the window is maximized.

Okay, now we’re actually done

At least until Windows decides they want to shake up the title bar style again.