NativeDesktop

Web

Build the same app for the browser with nd build --target web: one canvas, the plain GPUI look, and checks for what a browser cannot do.

An app that builds for the desktop builds for the web unchanged. nd build --target web compiles it to wasm32-unknown-unknown, and the page draws it on one canvas through WebGPU, with a WebGL2 fallback. The hello, overrides, gallery and scroll examples run this way, and the embeds on this site run the live build.

Example: Hello. The smallest app: a toolbar button, a count and window controls placed by coordinates. Source in examples/hello. It also runs live in the docs app.
Hello on macOS

Captured on macOS 27.0, 2026-10-14.

Build and serve

nd build --target web -p hello       # target/nd/web/hello/
nd serve --target web -p hello       # build, then serve on 127.0.0.1:8080

The bundle is index.html, the wasm, its JavaScript loader and the app's assets/ folder. --out picks another folder (it refuses to clear one that holds something else), --debug skips optimization, --port and --no-build apply to serve. wasm-opt -Oz runs when it is on the PATH. nd installs a wasm-bindgen CLI that matches the locked wasm-bindgen crate into ~/.cache/nd/tools, never system-wide, or uses the one named by ND_WASM_BINDGEN. The app has to depend on wasm-bindgen.

[web] base_path in nativedesktop.toml adds a <base> tag for an app served under a sub-path such as /notes/. There is no packaging step beyond this folder: nd package builds installers for the three desktop systems only, and any static host serves the bundle.

What it looks like

The web draws the plain default GPUI look. It imitates no OS: there is no macOS, Fluent or GNOME skin in the bundle, and the fonts are the bundled Noto Sans and Noto Sans Mono. The same code stays native on each desktop. Per-platform closures reflect that: .on_macos, .on_windows and .on_linux do not run on the web, and .on_web does.

// 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()),

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

One canvas

The browser has one canvas, so every window of the app lives inside it. The first window fills the canvas. Later windows float over it with a drawn title row, sheets sit over a scrim, and each one blocks input to what is below it. The menu bar is a drawn row with dropdowns, and context menus are drawn overlays. Window tabs are separate OS windows on the desktop and have no web counterpart (see windows and tabs).

Feature checks

Not every API can work in a browser. nd::platform::has(Feature) answers at runtime and needs no window or context, because the target decides:

// A target decides the answer, so no window or context is needed to ask.
let tray_row = nd::platform::has(nd::platform::Feature::Tray)
    .then(|| self.row(cx, "Tray", vec![("tray", tray_label, Action::Tray)]).into_any_element());

From examples/system/src/lib.rs, line 336.

On the web only Feature::Clipboard is true: no file system, tray, notifications, credentials, threads, CEF, webview, updates or IPC, and no native-only widgets. Clipboard reads may still need a user gesture. The desktops answer true for everything except MenuBar and NativeOnlyWidgets (Linux and Windows), and Audio (Windows).

An API that cannot work on the web returns Err(Unsupported) there and carries #[nd::unavailable(web)]; the attribute takes web, macos, windows or linux. Calling it without a has check is a warning under nd check --target web.

Check the app builds for the browser

nd check --target web

Besides the usual lints it denies what compiles on wasm and panics when it runs: std::time::Instant and SystemTime (rule web-std-time), std::thread (web-std-thread) and std::fs (web-std-fs). It warns on each call to a #[nd::unavailable(web)] item that has no has check (web-unavailable), and runs clippy for wasm32-unknown-unknown on the library and binaries. // nd-allow(rule): reason silences one finding.

Use nd::time::{Instant, SystemTime, UNIX_EPOCH} for clocks: they are web-time's types in the browser and std's elsewhere. For storage use Store<T> and the file dialogs, and the executors for async work.

Settings and storage

nd-settings and Store<T> keep every file in localStorage under the file's path, with the same API as on disk. There is no file watcher and no Nix layer. nd::files and nd::clipboard work on every target: file open is a hidden <input type=file>, save is a download, and the clipboard is the async Clipboard API.

Tests

Tier 1 tests run in a browser too. #[nd::test] is libtest on the desktop and wasm-bindgen-test in the browser, so one test body runs in both; scripts/web-test hello gallery runs the tier 1 suites in headless Chrome and fetches a matching chromedriver into the same tools folder. hello also has a golden of the web look, light and dark, drawn by headless Chrome from a fresh nd build --target web:

let bless = std::env::var_os("ND_BLESS").is_some_and(|v| v == "1");

From examples/hello/tests/web_golden.rs, line 92.

It runs only with ND_WEB_GOLDEN=1 (just web-test sets it). More in testing.

Gaps