Custom Window Chrome
Draw a custom title bar in HTML while keeping native window frame behaviors.
On this page
In Native Look and Feel, we added backdrop materials, a popup menu, and system themes to the 10-native-look-and-feel sample.
We'll now draw the window's title bar and caption buttons in HTML and CSS while keeping native drag, resize, and snap behaviors.
Create a Window with Custom Chrome
To extend page content across the entire window, create the window with WindowFlags::CustomChrome.
app_ = App::Create();
window_ = Window::Create(app_->main_monitor(), 900, 620, false,
WindowFlags::CustomChrome | WindowFlags::Resizable |
WindowFlags::Maximizable);
window_->set_listener(this);
WindowFlags::CustomChrome extends the page over the title bar area and implies WindowFlags::Titled.
MyApp acts as the WindowListener for both the main window and the popup menu.
Native Frame Behaviors
The OS preserves its standard frame behaviors, including resize edges, snap gestures, and the system window menu.
On Windows and macOS, the window also keeps its system drop shadow and rounded corners.
On Linux, the window manager controls corner rounding, and the window has no system shadow.
📘 Linux display servers
Custom chrome on Linux requires X11 or XWayland. Native Wayland isn't supported.
Draw the Title Bar in HTML
The title bar is ordinary HTML inside chrome.html— it contains the application title, an Appearance button, and three caption buttons (#minimize, #maximize, and #close).
Mark the Drag Area
To let users drag the window by its title bar, set app-region: drag in CSS.
.titlebar {
height: 46px;
app-region: drag;
}
.menu-pill,
.window-buttons {
app-region: no-drag;
}
Dragging the title bar moves the window, and double-clicking triggers the platform's title bar action— no native code is needed.
Like a native title bar, a drag region intercepts left clicks before they reach the page. Set app-region: no-drag on clickable elements inside the title bar (such as the Appearance button and caption buttons) so the page receives their clicks.
Wire the Caption Buttons
To connect the caption buttons to window actions, register click listeners for each button.
chrome_listeners_.On("#minimize", "click", [this] { window_->Minimize(); });
chrome_listeners_.On("#maximize", "click", [this] {
window_->is_maximized() ? window_->Restore() : window_->Maximize();
});
chrome_listeners_.On("#close", "click", [this] { window_->Close(); });
Register these handlers on chrome_listeners_ before attaching the trigger set to the panel's View and loading the page.
On Windows and Linux, these handlers act as a fallback— hit-test regions intercept clicks on the caption buttons first.
Adapt the Chrome to Each Platform
Platforms handle caption buttons differently when a window uses custom chrome.
On Windows and Linux, the window has no native buttons— the page draws its own caption buttons in HTML.
On macOS, the native window buttons (the traffic lights) stay and float over the page.
Choose the Platform Path When the DOM Is Ready
Listen for OnDOMReady to store the page's root element and adapt the chrome.
chrome_listeners_.OnDOMReady([this](dom::Document document) {
chrome_root_ = document.documentElement();
ApplyPlatformChrome();
});
In ApplyPlatformChrome(), check Window::window_control_bounds() to choose between platform layouts.
void ApplyPlatformChrome() {
if (window_->window_control_bounds().IsEmpty()) {
// Windows and Linux: the page's own buttons act as the real ones.
page_buttons_ = true;
UpdateHitTestRegions();
return;
}
// macOS: move the native buttons into our title bar and tell the page
// how much room to leave them.
window_->SetWindowControlInset(20, 14.5);
Rect controls = window_->window_control_bounds();
chrome_root_.classList.add("native-controls");
chrome_root_.style.setProperty("--controls-clearance",
std::to_string((int)controls.right + 12) + "px");
}
Window::window_control_bounds() returns the rectangle covering the native caption buttons. Because only macOS custom-chrome windows keep these buttons, the rectangle is empty on Windows and Linux— native code uses that check to determine the platform.
Register Button Regions on Windows and Linux
To turn HTML elements into native caption buttons, define hit-test regions using Window::SetHitTestRegions().
void UpdateHitTestRegions() {
float width = window_->width();
HitTestRegion regions[] = {
{ HitRegionRole::Minimize, Rect::FromXYWH(width - 138, 0, 46, 46) },
{ HitRegionRole::Maximize, Rect::FromXYWH(width - 92, 0, 46, 46) },
{ HitRegionRole::Close, Rect::FromXYWH(width - 46, 0, 46, 46) },
};
window_->SetHitTestRegions(regions, 3);
}
Hit-test regions map window content coordinates in logical pixels to system button roles.
Clicking a button region triggers the OS window action directly, while mouse hover events still pass to the page so CSS :hover styles work.
On Windows 11, hovering over a HitRegionRole::Maximize region displays the native snap layouts menu.
Update Regions on Resize
Because hit-test regions don't move when the window resizes, update them whenever the window dimensions change.
void OnResize(ultralight::Window* window, double width,
double height) override {
if (window == window_.get() && page_buttons_)
UpdateHitTestRegions();
}
Recompute the button rectangles inside WindowListener::OnResize() so buttons along the right edge stay aligned with their hit regions.
🚧 Guard hit-test registration
Register hit-test regions only after the DOM is ready and only when the page draws its own caption buttons. On macOS, the page's caption buttons are hidden, so hit-test regions in that corner would cover the Appearance button and intercept its clicks.
Make Room for Native Buttons on macOS
Window::SetWindowControlInset() moves the native buttons inside the HTML title bar. Calling Window::window_control_bounds() after applying the inset returns the rectangle covering the buttons— native code uses its right edge plus 12 pixels to set the --controls-clearance property.
On the page, use CSS to size a spacer element and hide the HTML caption buttons.
.titlebar .clearance {
width: var(--controls-clearance, 14px);
}
.native-controls .window-buttons {
display: none;
}
For more window chrome options, such as defining drag regions or choosing available caption buttons from native code, see Native Window Styling.
Restyle the Chrome on Window Changes
To update the appearance of the title bar when window states change, native code toggles CSS classes on the page's root element.
Because the main window and popup menu share the same WindowListener, verify that the callback target matches the main window before modifying classes.
Swap the Maximize and Restore Icons
Listen for WindowListener::OnWindowStateChanged() to detect when the window is maximized, minimized, or restored.
void OnWindowStateChanged(ultralight::Window* window,
WindowState state) override {
if (window == window_.get())
chrome_root_.classList.toggle("is-maximized",
state == WindowState::Maximized);
}
On the page, use the .is-maximized class to toggle between the maximize and restore button icons.
.win-btn .restore { display: none; }
.is-maximized .win-btn .maximize { display: none; }
.is-maximized .win-btn .restore { display: block; }
This event fires whether the state change originated from a caption button click, a snap gesture, or a call to Window::Maximize().
Dim the Title Bar When Inactive
Listen for WindowListener::OnActivationChanged() to dim the title bar when the window loses focus.
void OnActivationChanged(ultralight::Window* window, bool active) override {
if (window == window_.get())
chrome_root_.classList.toggle("is-inactive", !active);
}
On the page, reduce the opacity of the title bar when the .is-inactive class is present.
.is-inactive .titlebar { opacity: 0.5; }
Opening the window's own popup menu doesn't change window activation— the title bar stays active while its menus are open.
What's Next
From here, Shipping Your App walks through packaging the application for distribution across platforms.