Skip to content

Window chrome

A window’s chrome is its titlebar, frame and controls. Sabine windows either keep the system’s, or leave the whole window to the page, which then draws a titlebar of its own.

Builder call Chrome
.system_chrome() The system’s titlebar, frame and controls. The default, and what .app() uses
.app_chrome(AppChrome) The page draws the titlebar; Sabine provides the drag strip and the window controls’ behavior. On macOS the system’s traffic lights stay
.frameless() No titlebar or frame. The page draws and handles everything
.chrome(SabineWindowChrome::Sabine) A plain 38-pixel titlebar Sabine draws above the page, with the window title and round minimize, maximize and close buttons

.chrome(SabineWindowChrome) takes any of them as a value: System, Sabine or Frameless.

On Windows 11, windows without system chrome keep the system’s rounded corners; Windows 10 keeps them square.

AppChrome is the recommended way to draw your own titlebar. It lays out a titlebar strip that moves the window, window controls at its right end that Sabine handles natively, and, for glass windows, the regions of a sidebar layout:

use sabine::prelude::*;
fn main() {
SabineWindow::main(|window| {
Ok(window
.app()
.glass()
.app_chrome(AppChrome::default())
.min_size(720, 480))
});
}
Field Default Meaning
titlebar 38 Height of the titlebar strip in logical pixels
sidebar 260 Width of a sidebar along the left edge, below the titlebar. 0 for none
radius 14 Corner radius of the window’s regions
control_width 46 Width of each window control

AppChrome::new(titlebar, sidebar) sets the first two and keeps the other defaults. For a layout without a sidebar:

window.app_chrome(AppChrome { sidebar: 0, ..AppChrome::default() })

app_chrome sets up the window differently on each desktop:

The window turns frameless. The strip across the top, titlebar pixels high, moves the window. Three control areas at its right end, each control_width wide and as high as the titlebar, minimize, maximize and close it, in that order from left to right. The page draws the buttons there.

With a sidebar, app_chrome also sets the blur, opaque and input regions of a glass sidebar layout: the titlebar and sidebar blur what is behind the window, the content area beside the sidebar is opaque, and input follows the window’s rounded shape. They only change how the window looks when it is glass. See Glass and effects to change them.

Clicks in the control areas never reach the page: Sabine handles them like the system’s own buttons. Maximize toggles between maximized and restored. The page still receives pointer movement there, so :hover styles on the buttons it draws work as usual. Draw the buttons where Sabine expects them and mark them aria-hidden, since they are only pictures of the controls:

<header class="titlebar">
<span class="title">Notes</span>
<div class="window-controls" aria-hidden="true">
<span class="control minimize"></span>
<span class="control maximize"></span>
<span class="control close"></span>
</div>
</header>
.titlebar {
display: flex;
align-items: center;
height: 38px;
padding-inline-start: calc(var(--sabine-controls-left, 0px) + 16px);
-webkit-app-region: drag;
app-region: drag;
}
.window-controls {
display: flex;
margin-inline-start: auto;
}
.control {
width: 46px;
height: 38px;
}
.control:hover {
background: rgb(255 255 255 / 0.08);
}
.control.close:hover {
background: #c42b1c;
}
:root[data-controls="system"] .window-controls {
display: none;
}

The page needs to know whether to draw controls at all, and how much room the system’s controls take. appWindow.controlsOverlay() reports the area of the window’s top corners that the system’s controls cover, in logical pixels:

import { appWindow, type ControlsOverlay } from "@lantharos/sabine";
function placeControls({ left, right }: ControlsOverlay) {
document.documentElement.dataset.controls = left || right ? "system" : "page";
}
appWindow.onControlsOverlayChanged(placeControls);
appWindow.revealWhen(appWindow.controlsOverlay().then(placeControls));

With AppChrome on macOS, left is the width of the traffic lights and height their height. Everywhere else all three are zero, because the page draws its own controls. The overlay changes, and onControlsOverlayChanged fires, when the window enters or leaves fullscreen.

Sabine also sets the same values on every page as CSS properties, so the layout can follow them without script:

Property Value
--sabine-controls-left Width the system’s controls cover at the top left
--sabine-controls-right Width they cover at the top right
--sabine-controls-height Height they cover

Passing the overlay to revealWhen keeps the window off screen until the page has placed its controls, so it never appears with the wrong layout for a frame.

Parts of the window that move it when dragged are drag regions. A double click on one maximizes or restores the window, unless it can’t be resized. A press in a drag region goes to the window system, not to the page, so buttons and fields inside one need to be marked as not draggable.

Pages mark regions with CSS:

.titlebar {
-webkit-app-region: drag;
app-region: drag;
}
.titlebar button,
.titlebar input {
-webkit-app-region: no-drag;
app-region: no-drag;
}

Chromium reads both the prefixed and the standard property. Page regions follow the layout as it changes, and work in every kind of chrome.

Rust can add fixed regions, in logical pixels from the window’s top left:

Builder call Region
.titlebar_drag_region(height) The full width of the window, height pixels from the top
.drag_region(WindowRegionRect) A rectangle that moves the window
.drag_exclusion_region(WindowRegionRect) A rectangle that never moves the window, even inside a drag region

A negative x counts from the window’s right edge, and a width of i32::MAX reaches the right edge, so regions follow the window’s width:

use sabine::WindowRegionRect;
window
.frameless()
.titlebar_drag_region(44)
.drag_exclusion_region(WindowRegionRect::new(-180, 0, 180, 44))

Page no-drag regions take precedence over Rust drag regions, so app-region: no-drag makes a titlebar button clickable even inside AppChrome’s strip.

Without drag regions of either kind, a window with Sabine’s own titlebar moves from that titlebar, and a frameless window doesn’t move at all.

For anything regions can’t express, start a move from a mousedown handler with appWindow.startDrag():

handle.addEventListener("mousedown", (event) => {
if (event.button === 0) appWindow.startDrag();
});

A frameless window without AppChrome has no controls until the page draws them. Buttons call the window API:

<div class="controls">
<button id="minimize" aria-label="Minimize"></button>
<button id="maximize" aria-label="Maximize"></button>
<button id="close" aria-label="Close"></button>
</div>
import { appWindow } from "@lantharos/sabine";
document.querySelector("#minimize")?.addEventListener("click", () => appWindow.minimize());
document.querySelector("#maximize")?.addEventListener("click", () => appWindow.toggleMaximize());
document.querySelector("#close")?.addEventListener("click", () => appWindow.close());
appWindow.onStateChanged(({ maximized }) => {
document.querySelector("#maximize")?.setAttribute("aria-label", maximized ? "Restore" : "Maximize");
});

Give the buttons app-region: no-drag when they sit in a drag region.

Call Effect
appWindow.minimize() Minimizes the window
appWindow.maximize() Maximizes it; does nothing when it already is
appWindow.toggleMaximize() Maximizes it, or restores it when it is maximized
appWindow.restore() Leaves fullscreen, maximized and minimized states
appWindow.setFullscreen(enabled) Enters or leaves fullscreen
appWindow.close() Closes the window, or hides it with .hide_on_close(true)

appWindow.maximized, appWindow.fullscreen and appWindow.tiled report the window’s state, and appWindow.onStateChanged hears about changes. tiled is true while a Wayland compositor has snapped the window against a screen edge.

Page buttons work everywhere, including inside drag regions marked no-drag. To have Sabine handle a control natively instead, as AppChrome does, give it a control region:

use sabine::{SabineWindowControlAction, WindowRegionRect};
window
.frameless()
.titlebar_drag_region(40)
.control_region(SabineWindowControlAction::Close, WindowRegionRect::new(-40, 0, 40, 40))

A window the desktop has maximized, made fullscreen or tiled meets the screen’s edges, so rounded corners there look wrong. Sabine squares the corners of the window’s regions in those states by itself. Pages that draw rounded corners can follow the same states:

appWindow.onStateChanged(({ maximized, fullscreen, tiled }) => {
document.documentElement.classList.toggle("docked", maximized || fullscreen || tiled);
});

Windows with system chrome resize through their frame. Windows without it resize from their edges:

Sabine reserves a 7-pixel band along the edges of resizable windows and starts the compositor’s interactive resize from it. Presses there don’t reach the page. On Kestrel, which resizes windows from just outside their edges, Sabine reserves no band, so scrollbars at the edge of the page stay grabbable.

Where several overlap, Sabine looks at a press in this order: a resize edge, then a control area, then a drag region, and only then the page.