NativeDesktop

Native widgets

Define your own widget with the NativeWidget trait: a drawn version for every OS, and a native view where the OS has one.

When div, text, and canvas are enough, write a widget as a plain function or a RenderOnce struct. Implement NativeWidget when the OS has a real control that you want, such as a map, or when the widget must be a native view to behave like one. The framework handles layout, focus, and z-order the same way for both kinds, and a call site can't tell your widget from a built-in one. Every built-in control in nd-components uses this same trait.

The Places example has a map and a gauge. On macOS, the map is MapKit's MKMapView through objc2, and the gauge is a SwiftUI Gauge (Swift). On other platforms, nd draws both.

On AppKit, a widget with an objc2 or Swift implementation mounts as its own view. The backend frames, measures, and updates that view like a built-in control. Everywhere else, and on AppKit when a Swift widget's symbols aren't in the binary, the element mounts its drawn tree inside a container that takes the element's id, layout style, and semantics.

Example: Places. App-defined native widgets: an objc2 MapKit view and a Swift-hosted SwiftUI gauge, each with a drawn fallback. Source in examples/native-widget.
Places on macOS

Captured on macOS 27.0, 2026-10-22.

Places on Linux

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

Places on Windows

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

The trait

A widget names its props, its events, what assistive technology and automation see, and its implementations:

pub struct MapWidget;

impl NativeWidget for MapWidget {
    type Props = MapProps;
    type Event = MapEvent;

    fn semantics(props: &MapProps) -> Semantics {
        Semantics::new(Role::Group).label("Map").value(format!("{} places", props.pins.len()))
    }

    fn drawn(props: &MapProps, emit: Emit<MapEvent>, window: &mut Window, cx: &mut App) -> AnyElement {
        drawn::render(props, emit, window, cx)
    }

    fn backends() -> Backends<Self> {
        let backends = Backends::drawn_only();
        #[cfg(target_os = "macos")]
        let backends = backends.appkit(appkit::MapKit);
        backends
    }
}

From examples/native-widget/src/map_view/mod.rs, line 49.

An element type wraps the props and implements NativeElement (base_mut and into_parts). NativeBase<W> carries the id, layout style, and event handlers, and #[derive(IntoElement)] mounts it. Builder methods such as .zoom(..) and .pins(..) set props, and .on_region_change(..) registers an event handler. For the full set, see map_view/mod.rs. The call site reads like any built-in:

.content(
    map_view("map", self.center)
        .zoom(self.zoom)
        .pins(pins)
        .size_full()
        .on_region_change(cx.listener(|this, e: &RegionChanged, _window, cx| {
            this.center = e.center;
            this.zoom = e.zoom;
            cx.notify();
        }))
        .on_pin_select(cx.listener(|this, e: &PinSelected, _window, cx| {
            if let Some(place) = this.places.iter_mut().find(|p| p.name == e.title.as_ref()) {
                place.visited = true;
                cx.notify();
            }
        })),
)

From examples/native-widget/src/main.rs, line 65.

The drawn fallback

The fallback in drawn.rs uses a canvas to paint a 3x3 block of OpenStreetMap tiles around the center. It draws the pins on top and pans when you drag. It's a real map, with the attribution that OpenStreetMap requires, and the web and Linux builds ship it. Write the fallback first. It defines the props and events, and the native version then has to match them.

The AppKit side

AppKitWidget creates an NSView, updates it when the props change, and reports its size:

fn create(&self, props: &MapProps, cx: &mut AppKitCx<MapWidget>) -> (Retained<MKMapView>, Self::State) {
    let map = unsafe { MKMapView::new(cx.mtm()) };
    let delegate = MapDelegate::new(cx.emitter(), cx.mtm());
    // MKMapView holds its delegate weakly; the node state keeps it alive.
    unsafe { map.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
    apply(&map, &delegate, None, props, cx);
    (map, delegate)
}

From examples/native-widget/src/map_view/appkit.rs, line 31.

AppKitCx gives you the main-thread marker, emitter() for delegates that the view installs, and programmatic(..). State is whatever you want to keep beside the view. Here, it's the delegate, because MKMapView holds its delegate weakly. update receives the old and new props and applies the difference:

fn apply(
    map: &MKMapView,
    delegate: &MapDelegate,
    old: Option<&MapProps>,
    new: &MapProps,
    cx: &mut AppKitCx<MapWidget>,
) {
    let viewport: Viewport = (new.center, new.zoom);
    delegate.ivars().viewport.borrow_mut().apply(&viewport, |&(center, zoom)| {
        let region = MKCoordinateRegion { center: to_cl(center), span: span_for_zoom(zoom) };
        // Programmatic: the regionDidChange this triggers is reported, not emitted.
        cx.programmatic(|| unsafe { map.setRegion_animated(region, old.is_some()) });
        shown(map)
    });
    if old.is_none_or(|o| o.pins != new.pins) {
        set_pins(map, &new.pins);
    }
}

From examples/native-widget/src/map_view/appkit.rs, line 59.

measure returns the view's natural size. A map has no natural size, so it returns zero, and the app sizes it with flex or explicit dimensions.

Callbacks and controlled values

Two rules keep a native view from fighting the app's state.

No native callback calls app code. AppKit invokes delegates inside setters and layout: setRegion fires regionDidChange before it returns, while the app's view is in the middle of an update. Emit queues the event, and the queue drains after the update, outside any borrow of the app's state. Call emit(..) from a delegate, and never run app code there.

define_class!(
    #[unsafe(super(NSObject))]
    #[thread_kind = MainThreadOnly]
    #[ivars = MapIvars]
    pub struct MapDelegate;

    unsafe impl NSObjectProtocol for MapDelegate {}

    unsafe impl MKMapViewDelegate for MapDelegate {
        #[unsafe(method(mapView:regionDidChangeAnimated:))]
        fn region_did_change(&self, map: &MKMapView, _animated: bool) {
            let (center, zoom) = shown(map);
            // Busy while `apply` runs the setter, which records what the map shows itself.
            if let Ok(mut viewport) = self.ivars().viewport.try_borrow_mut() {
                viewport.report((center, zoom));
            }
            // Queued, never delivered inside this callback; dropped if a programmatic set caused it.
            self.ivars().emit.emit(MapEvent::RegionChanged(RegionChanged { center, zoom }));
        }

        #[unsafe(method(mapView:didSelectAnnotation:))]
        fn did_select(&self, _map: &MKMapView, annotation: &ProtocolObject<dyn MKAnnotation>) {
            if let Some(title) = unsafe { annotation.title() } {
                self.ivars().emit.emit(MapEvent::PinSelected(PinSelected { title: title.to_string().into() }));
            }
        }
    }
);

From examples/native-widget/src/map_view/appkit.rs, line 97.

A controlled value never echoes. The map is controlled: the app owns center and zoom, and when the user pans, the map emits RegionChanged, which the app feeds back in. Without care, that loops. MapKit makes it worse, because it fits any region to its aspect ratio, so the region that it reports is never the one you set. Synced<T> holds what the native control holds:

When the app feeds the reported region back, no second setRegion happens.

/// What the map shows, in the props' own terms. MKMapView fits any region to its aspect ratio,
/// so the region it reports differs from the one we set; `Synced` remembers the reported one and
/// the app feeding it back causes no second `setRegion`.
type Viewport = (LatLng, f64);

From examples/native-widget/src/map_view/appkit.rs, line 20.

Do the same for any prop that the user can also change. A slider's step snapping and a split divider's clamp are the same case.

Build and run

The map crate depends on objc2-map-kit only for macOS. The native-widget package is named places, and an m6 feature gates the binary:

cargo run -p places --features m6

nd re-exports the objc2 types under nd::native::appkit, so you don't need an extra dependency for AppKit itself.