NativeDesktop

Windows and tabs

Open windows with WindowOptions, use several windows and monitors, place window controls and drag regions, group window tabs with tab_group, and use the in-window tab, split, and pane widgets.

A window is a root view that you pass to cx.open_window. This page covers what you can ask of the window itself, and then tabs. Tabs come in two kinds: window tabs, where each tab is a window, and in-window tabs, where the tabs are one view.

Example: Tabs. Browser-style window tabs: every tab is a window of one tab group, with the system tab bar on macOS. Source in examples/tabs.
Tabs on macOS

Captured on macOS 27.0, 2026-10-22.

Tabs on Linux

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

Tabs on Windows

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

Opening a window

pub fn app() -> Application {
    Application::new(nd::manifest!())
}

pub fn open(cx: &mut App) -> WindowHandle<Counter> {
    cx.open_window(WindowOptions::main("main").title("Hello"), |_window, cx| cx.new(|_cx| Counter { count: 0 }))
}

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

WindowOptions::main(id) takes an id and these methods:

MethodEffect
.title(..)The title. On a native tab, it's the tab's label. To change it later, call cx.set_window_title.
.size(..), .min_size(..)The initial and minimum content size
.titlebar(Titlebar::Custom)Content runs under a transparent title bar, and you draw the row
.kind(..)Normal, Sheet { parent }, or Settings
.tab_group(..)Opens the window as a tab of a group (see window tabs)

Sheets and the settings window have helpers, so you don't build the options by hand. window.open_sheet(cx, build) opens a window-modal sheet on macOS and a dialog with a scrim on other platforms:

fn open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context<Self>) {
    window.open_sheet(cx, |window, cx| cx.new(|cx| SettingsSheet::new(window, cx)));
}

From examples/notes/src/app.rs, line 118.

open_settings(cx, build) opens the settings window. If it's already open, the call brings it forward and returns None. On macOS, it's a preference-style window, and the app menu's Settings item (Cmd-,) opens it when the app binds that key.

/// Opens the settings window, or brings it forward.
pub fn open_settings_window(cx: &mut App) -> Option<WindowHandle<Settings>> {
    open_settings(cx, |_, cx| cx.new(|_| Settings { tab: "general".into(), density: 0, size: 15. }))
}

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

Several windows

You can open as many windows as you need. Each open_window call creates an independent OS window with its own root view. Windows share the app's entities, so to share state between them, clone an Entity into each root. The pacing example opens two:

fn main() -> nd::Result<()> {
    pacing::app().run(|cx| {
        for name in ["pacing-a", "pacing-b"] {
            pacing::open(cx, name);
        }
    })
}

From examples/pacing/src/main.rs, line 1.

To close a window, call cx.close_window(id). When a window has a Window::on_should_close handler, a close by the user goes to it first. For details, see held closes.

Monitors and frame pacing

Each window runs its own display clock at the refresh rate of the display that it's on. If you drag one window to a 240 Hz monitor, the other one stays at 144 Hz. window.frame_time() gives the clock's FrameTime: the frame count, when the last frame was presented, and when the frame being prepared will be presented. Sample animations at window.now(), which is the target time, and they stay smooth at any rate.

An idle window requests no frames. To animate, call cx.notify() during render so that the next frame happens, as the pacing example does:

// Rendering again next frame keeps the window animating at its display's rate.
cx.notify();
let time = window.frame_time();
let hz = if time.interval() > 0. { 1. / time.interval() } else { 0. };
v_flex()
    .size_full()
    .items_center()
    .justify_center()
    .gap_1()
    .child(text(self.name).text_size(px(20.)))
    .child(text(format!("frame {}", time.frame)).tabular_nums())
    .child(text(format!("{hz:.0} Hz display")).tabular_nums())

From examples/pacing/src/lib.rs, line 14.

The framework throttles only occluded or minimized windows. A visible window that isn't focused, such as a lyrics window on the second monitor, keeps its display's rate while it animates.

ND_PACING=1 makes the GPUI host print nd-pacing: lines with the measured rate of each window. The macOS host uses a display link per window. The Windows host paces each monitor on its own vblank, checked at 240 Hz and 144 Hz. For details, see STATUS.md.

Window controls and drag regions

The close, minimize, and zoom controls belong to the OS. You choose where they sit.

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

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

On Linux, the framework draws the controls only when the compositor leaves decorations to the client and lists the action. Under Hyprland, nothing is drawn and nothing takes space.

drag_region() is a div whose background moves the window. Pointer-down starts the OS move. A double click zooms the window on macOS and maximizes it on other platforms. Buttons and other interactive children keep their clicks.

Titlebar insets

With Titlebar::Custom, you draw the title row, and titlebar() reserves the space that the OS controls need. That space is on the leading side on macOS, the trailing side on Windows, and whichever side the client-decorated Linux window uses. Under Hyprland, there's none. Where the row is empty, it drags the window.

// 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.

To lay out your own row, call window.titlebar_insets(). It returns the leading, trailing, and height values that the backend measured, and .titlebar_insets() on a div pads it by them. Don't tune a margin by hand, because the values change with each OS release.

Window tabs

Windows that name the same group show as tabs of one window. On macOS, this uses NSWindow tabbing. On other platforms, it's a drawn tab bar in one window. The framework reads the group when the window opens, and groups with different names never merge.

let options = WindowOptions::main("tab").title("New Tab").size(size(px(900.), px(600.))).tab_group(GROUP);
let browser = browser.clone();
let handle = cx.open_window(options, move |window, cx| {
    let new_tab = browser.clone();
    window.on_new_tab(move |_, cx| {
        open_tab(cx, &new_tab);
    });
    let closing = browser.clone();
    window.on_should_close(move |window, cx| {
        // The app drops the tab's page and closes the window itself; returning false keeps
        // the platform from closing it a second time.
        close_tab(cx, &closing, window.id());
        false
    });

From examples/tabs/src/lib.rs, line 105.

The tabs example is a browser. Each tab is a TabWindow that holds a Page, and the app keeps a Browser entity with the list of tabs. Here's what the handlers do:

.on_action(cx.listener(|_: &mut TabWindow, _: &ShowAllTabs, window, cx| {
    cx.window_command(window.id(), nd::backend::WindowCommand::ToggleTabOverview);
}))

From examples/tabs/src/lib.rs, line 90.

Held closes

The platform holds a tab's close until the app agrees. The handler returns false to keep the platform from closing the window, drops the tab's page, and then closes the window itself.

/// Drops the tab's page, then closes its window.
pub fn close_tab(cx: &mut App, browser: &Entity<Browser>, window: WindowId) {
    let removed = browser.update(cx, |b, _| {
        let at = b.tabs.iter().position(|t| t.window == window)?;
        let tab = b.tabs.remove(at);
        Some(tab.page)
    });
    if let Some(page) = removed {
        let number = page.read(cx).number;
        browser.update(cx, |b, _| b.closed.push(number));
    }
    cx.close_window(window);
}

From examples/tabs/src/lib.rs, line 130.

This order matters for a tab that has a native surface in it. If the platform closed the window first, it would tear down a web view before the app saw the close. Without a handler, a close request closes the window immediately.

Moving tabs

Drag a tab out of the bar to make a new window, or into another window of the same group to move it there. A view that moves to another window keeps its native nodes, so a webview doesn't reload. Tabs from different groups refuse each other.

Tabs inside a window

tab_view(id) is a tab strip over a stack of pages. tab_strip(id) is the strip alone, for when you switch the content yourself. Selection is controlled: the strip reports TabChanged, and you pass the id back with .selected(..).

tab_view("documents")
    .presentation(TabStyle::Strip)
    .add_button(true)
    .selected(self.document.clone())
    .h(px(140.))
    .tabs(documents)
    .on_select(cx.listener(|this, e: &TabChanged, _, cx| {
        this.document = e.id.clone();
        cx.notify();
    }))
    .on_close(cx.listener(|this, e: &TabCloseRequested, _, cx| {
        this.documents.retain(|d| *d != e.id);
        if this.document == e.id {
            this.document = this.documents.first().cloned().unwrap_or_default();
        }
        this.last = format!("Closed {}", e.id).into();
        cx.notify();
    }))
    .on_add(cx.listener(|this, _: &TabAddRequested, _, cx| {
        let name: SharedString = format!("Document {}", this.next).into();
        this.next += 1;
        this.documents.push(name.clone());
        this.document = name;
        cx.notify();
    })),

From examples/gallery/src/layout.rs, line 89.

Settings windows that put their tabs in the toolbar use toolbar().tab(..), as shown in chrome.

Split views

split(id, Axis) has two panes and a divider that the user drags. It's an NSSplitView on macOS and a drawn divider on GPUI backends.

split("body", Axis::Horizontal).ratio(0.3).first(sidebar).second(content).on_resize(listener)

The ratio is controlled. A drag reports SplitResized { ratio }, and passing the same ratio back changes nothing. split_pane() wraps a pane to give it a min_size and max_size, and a drag stops at those limits. On macOS, split panes lay out again on the next turn of the run loop.

Pane trees

For splits that the user creates, panes is a pure model plus the elements that draw it. The model is a binary tree of splits and leaves, with one focused leaf. When nothing changes, every operation returns a value equal to its input that shares its Rc. So a native echo of a ratio that the model already holds can't start a render-and-save loop. To compare two trees, use PaneTree::same.

pub fn new(_cx: &mut Context<Self>) -> Self {
    let tree = panes::seed_panes(vec![SharedString::from("Editor"), SharedString::from("Preview")]);
    Self { tree, next: 1 }
}

fn split(&mut self, axis: panes::Axis) {
    let focused = self.tree.focused_id().clone();
    let name: SharedString = format!("Pane {}", self.next).into();
    self.next += 1;
    self.tree = panes::split_pane(&self.tree, &focused, axis, name, None);
}

From examples/gallery/src/layout.rs, line 127.

seed_panes, split_pane, close_pane, focus_pane, focus_neighbor, and set_pane_ratio cover editing, and serialize_panes and migrate_panes persist a layout. pane_tree draws a tree. Your closure draws each leaf and receives whether that leaf is the focused one.

pane_tree("panes", &self.tree, move |leaf, focused| {
    let id = leaf.id.clone();
    let weak = weak.clone();
    div()
        .size_full()
        .p_3()
        .bg(raised)
        .when(focused, |d| d.border_2().border_color(accent))
        .on_tap(move |_, _, cx| {
            weak.update(cx, |this, cx| {
                this.tree = panes::focus_pane(&this.tree, &id);
                cx.notify();
            });
        })
        .child(label(leaf.data.clone()))
        .into_any_element()
})
.on_resize(cx.listener(|this, e: &PaneResized, _, cx| {
    let next = panes::set_pane_ratio(&this.tree, &e.split, e.ratio);
    if !next.same(&this.tree) {
        this.tree = next;
        cx.notify();
    }
})),

From examples/gallery/src/layout.rs, line 169.

Two more views follow the same approach. dock_view renders tab stacks as leaves, keeps every tab mounted, and has operations to add, close, move, and undock tabs and to dock one panel against another. tiles_view places freeform tiles on a column grid and pushes other tiles down when they collide. The gallery's Split view page runs the pane tree.