NativeDesktop

Patterns

Declare window structure such as the toolbar, sidebar, inspector, and titlebar once, and let each OS draw it.

Declare the structure of a window with intents (scaffold, toolbar, sidebar, inspector, titlebar), and let the OS draw it. On macOS, each intent is the system's own component, so a new macOS release restyles your app without any change from you. Don't rebuild a sidebar or toolbar from divs, because a pixel copy goes stale as soon as the OS changes. The Chrome example declares a Mail-style window and names no AppKit class.

Example: Chrome. The window structure a Mail-style app declares: scaffold, source list, toolbar, inspector, menu bar, alert, file picker and a settings window. Source in examples/chrome.
Chrome on macOS

Captured on macOS 27.0, 2026-10-22.

Chrome on Linux

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Chrome on Windows

Captured on Windows 11 26H2, 2026-10-22.

What each intent becomes

IntentmacOS (AppKit)Linux and Windows (drawn through GPUI)
scaffold().sidebar()sidebar split item hosting a source listpane on the preset's sidebar surface
scaffold().inspector()inspector split itemright pane
toolbar()NSToolbar with system items and native item viewsheader row
titlebar()content under a transparent titlebar, inset for the traffic lightsinset for the caption buttons or the compositor's button-layout
window_controls()the real traffic lightscaption buttons only under client-side decorations
.context_menu()NSMenumenu drawn in the overlay stack
AlertNSAlert, a sheet on the windowdialog with a scrim
menu bar (set_menus)global NSMenuno bar: the items are folded into the command palette (the web draws a bar above its canvas)
settings windowtoolbar-tab window with grouped rows, cmd-,separate window
glass()NSGlassEffectView on macOS 26 and later, NSVisualEffectView beforethe preset's material: Acrylic on Windows, a transparent surface the compositor may blur on Linux

On Windows, the drawn chrome follows WinUI and is checked against Windows 11's own apps, such as Settings and Task Manager:

Form controls, dialogs, and menus still use the Adwaita look on Windows. Linux is checked on Hyprland.

Scaffold, toolbar, sidebar and inspector

scaffold() takes a toolbar, a sidebar, the content, and an inspector. Every part is optional. The toolbar holds system items (sidebar_toggle, inspector_toggle, flexible_space) next to your own buttons, and macOS gives them the system look and sizing.

.toolbar(
    toolbar()
        .title("Inbox")
        .sidebar_toggle()
        .item(icon_button("new", Icon::SquarePen).tooltip("New Message").dispatch(NewMessage))
        .item(icon_button("delete", Icon::Trash).tooltip("Delete").dispatch(DeleteMessage))
        .flexible_space()
        .inspector_toggle(),
)

From examples/chrome/src/lib.rs, line 64.

A search_field added with .item(..) follows each platform: in the toolbar on macOS, the title bar's content on Windows, and on Linux a toggle in the header that opens libadwaita's search bar under it. .persistent_search(search_field(..)) keeps the field in the Linux header instead, for a window whose search is always in use. .flexible_item(..) takes the room the row leaves and gives it back first when the window narrows, down to its content's least width: a path bar, so the buttons after it never run under the inspector.

The sidebar holds a source_list: sections of items, each with an icon and a badge. Your state owns the selection. On macOS, the list is an NSOutlineView in source-list style.

.sidebar(
    sidebar().min_w(px(180.)).child(
        source_list("folders")
            .section(
                "Mailboxes",
                [
                    source_item("inbox", "Inbox").icon(Icon::Inbox).badge("12"),
                    source_item("archive", "Archive").icon(Icon::Archive),
                    source_item("trash", "Trash").icon(Icon::Trash),
                ],
            )
            .section("Smart", [source_item("starred", "Starred").icon(Icon::Star)])
            .selected(self.folder.clone())
            .on_select(cx.listener(|this, e: &SourceSelected, _, cx| {
                this.folder = e.id.clone();
                cx.notify();
            })),
    ),
)

From examples/chrome/src/lib.rs, line 75.

Your state controls whether the inspector is collapsed, and its toggle sits in the toolbar:

.inspector(
    inspector()
        .collapsed(!self.inspector)
        .min_w(px(200.))
        .child(v_flex().p_3().child(text("Details").text_color(theme.text.secondary))),
)

From examples/chrome/src/lib.rs, line 107.

The user resizes a sidebar or inspector by dragging its divider. On macOS the split view's divider always drags; on Linux and Windows the pane's inner edge drags once you mark it .resizable(), between its min_w and max_w (150 and 480 points where unset). Every move reports PaneResized { width }. Pass the width back through .width(..) and store it to open the pane at the same width next time:

sidebar().resizable().width(px(self.sidebar_width)).on_resize(cx.listener(|this, e: &PaneResized, _, cx| {
    this.sidebar_width = e.width;
    cx.notify();
}))

A new .width(..) moves the divider; a drag the app does not take back still holds.

Key equivalents come from the bindings instead of the menu items. cx.bind_keys registers secondary-n, where secondary is Cmd on macOS and Ctrl on other platforms. set_menus builds the menu bar and shows each item's shortcut from the same binding.

cx.bind_keys([
    KeyBinding::new("secondary-n", NewMessage, Some("Mail")),
    KeyBinding::new("secondary-backspace", DeleteMessage, Some("Mail")),
    KeyBinding::new("secondary-o", OpenFile, Some("Mail")),
    KeyBinding::new("secondary-,", OpenSettings, None),
    KeyBinding::new("secondary-alt-i", ToggleInspector, Some("Mail")),
]);
set_menus(
    cx,
    [
        Menu::app([MenuItem::action("Settings…", OpenSettings)]),
        Menu::new("File")
            .items([MenuItem::action("New Message", NewMessage), MenuItem::action("Attach File…", OpenFile)]),
        Menu::new("Message").items([MenuItem::action("Delete", DeleteMessage).destructive()]),
        Menu::new("View").items([MenuItem::action("Show Inspector", ToggleInspector)]),
    ],
);

From examples/chrome/src/lib.rs, line 193.

Titlebar and window controls

A window with Titlebar::Native gets the OS titlebar, or the scaffold's toolbar inside it. For a custom row, open the window with Titlebar::Custom and build the row with titlebar(). It reserves the room that the OS controls need: the traffic lights on the left on macOS, the caption buttons on the right on Windows, and GNOME's button-layout on Linux. On Hyprland, the compositor draws no controls, so it reserves nothing. The reserved room updates for fullscreen and right-to-left layouts.

// Pattern override: a custom titlebar even on macOS. `titlebar()` reserves the OS
// controls' side and size (traffic lights left on macOS, caption buttons right on
// Windows, nothing on Hyprland), so no margin is tuned by hand.
.child(
    titlebar()
        .title("Overrides")
        .trailing(
            // Per platform: glass bezel on macOS, accent style on Windows, pill on Linux.
            button("share", "Share")
                .icon(Icon::Share)
                .dispatch(Share)
                .on_macos(|b| b.bezel(Bezel::Glass))
                .on_windows(|b| b.accent())
                .on_linux(|b| b.rounded_full().px_4())
                .on_web(|b| b.outline()),
        )
        // Coordinates instead of the default slot, macOS only.
        .on_macos(|t| t.controls(window_controls().at(point(px(18.), px(20.))))),
)

From examples/overrides/src/lib.rs, line 23.

window_controls() places the real buttons. With no argument, they take the slot that titlebar() reserves. .at(point(..)) pins them at window coordinates. Hello uses it to put the traffic lights where its layout wants them:

.child(window_controls().at(point(px(16.), px(10.))))

From examples/hello/src/lib.rs, line 25.

.embedded() makes the controls a layout slot instead. The backend moves the real buttons onto the slot's rectangle on every layout pass, so they move with a sliding sidebar. The buttons stay the OS's own, with their hover group, menu, and accessibility. On Linux, nd draws them only when the window has client-side decorations, so the same code shows no buttons on Hyprland.

Alerts, sheets and pickers

window.alert shows an Alert and calls back with the index of the button that the user selected. On macOS, the alert is attached to the window as a sheet by default, and .modal() shows it in front of the whole app. window.open_dialog opens the system file picker.

fn delete(&mut self, _: &DeleteMessage, window: &mut Window, cx: &mut Context<Self>) {
    let this = cx.weak_entity();
    let alert =
        Alert::new("Delete this message?").message("It moves to the Trash.").destructive("Delete").cancel("Cancel");
    window.alert(cx, alert, move |answer, cx| {
        let _ = this.update(cx, |this, cx| {
            this.status = if answer.0 == 0 { "Deleted".into() } else { "Kept".into() };
            cx.notify();
        });
    });
}

From examples/chrome/src/lib.rs, line 20.

Don't block in the callback. It runs after the dialog closes, while the app is free to handle events.

Settings

open_settings opens the app's settings window, or brings the open one to the front. On macOS, the window has the system preferences layout: a toolbar of tabs over grouped rows. It opens with cmd-, when the app binds OpenSettings. The tabs are a toolbar().tab(..), and the rows are a form of form_section and form_row:

.child(
    toolbar()
        .tab("general", "General", Icon::Settings)
        .tab("accounts", "Accounts", Icon::Inbox)
        .selected_tab(self.tab.clone())
        .on_tab(cx.listener(|this, e: &TabSelected, _, cx| {
            this.tab = e.id.clone();
            cx.notify();
        })),
)

From examples/chrome/src/lib.rs, line 165.

For settings that persist to a file, settings_form builds the whole window from a settings struct. For more information, see settings and config.

Glass

glass() is a container that always maps to the system material, never to a replica. It takes .variant(GlassVariant::Regular | Clear), .tint(color), and .interactive(). glass_group() merges neighboring glass where the OS does. Like any other element, glass() accepts every override. The Overrides example tints one:

// Subtree theme override: everything inside uses a warm accent and larger radii.
.child(
    glass()
        .variant(GlassVariant::Clear)
        .tint(theme::warm_glass_tint())
        .rounded_xl()
        .p_4()
        .gap_2()
        .theme(theme::warm)
        .child(text("Tinted glass with its own accent"))
        .child(
            h_flex().gap_2().child(button("ok", "Accept").primary()).child(button("no", "Decline")),
        )
        // Pattern override on one element: this context menu is drawn, not an NSMenu.
        .context_menu(|menu, _window, _cx| menu.item("Rename…", Rename).item("Share", Share))
        .pattern(Pattern::Drawn),
),

From examples/overrides/src/lib.rs, line 82.

Changing a pattern

If a pattern is wrong for your app, Overrides shows how to swap it for a drawn version. If a pattern looks wrong on an OS, that's a bug in the backend. Fix it there, so that every app gets the fix, instead of adding a margin to one app.