Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

NSTrackingArea on macOS: Hover State, Cursor Updates, and View Geometry

Implement AppKit hover and cursor behavior with deliberate tracking options, visible-rect updates, ownership, and resize-safe geometry.

An NSTrackingArea describes a rectangular region of an NSView that can receive pointer movement, enter/exit, or cursor-update events. It is view-owned geometry, not a global mouse listener. AppKit can update its relationship to a view as the view moves, and the inVisibleRect option can make the area track the view’s visible rectangle rather than a stale fixed rectangle.

Hover effects often fail after a resize because the app created one tracking rectangle and never recomputed it. Other bugs come from selecting an activity option that is too broad, leaving the feature active when the app is not frontmost, or assuming hover state represents a press or click. Treat pointer presence, first-responder status, and activation as separate state dimensions.

Create tracking areas from current view geometry

An area requires at least one tracking type option and one activity option. Tracking types include enter/exit, movement, and cursor updates. Activity options define when the events are active, such as when the view is in the key window or when the app is active. Add behavior options only when their semantics fit the interaction.

import AppKit

final class HoverCanvasView: NSView {
    private var hoverArea: NSTrackingArea?
    private var isPointerInside = false

    override func updateTrackingAreas() {
        if let hoverArea {
            removeTrackingArea(hoverArea)
        }

        let area = NSTrackingArea(
            rect: .zero,
            options: [.mouseEnteredAndExited, .mouseMoved, .inVisibleRect, .activeInKeyWindow],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        hoverArea = area
        super.updateTrackingAreas()
    }

    override func mouseEntered(with event: NSEvent) {
        isPointerInside = true
        needsDisplay = true
    }

    override func mouseExited(with event: NSEvent) {
        isPointerInside = false
        needsDisplay = true
    }
}

The .inVisibleRect option tells the tracking area to use the view’s visible rectangle; the rectangle passed at initialization is not the region to maintain manually. Without that option, derive and update a meaningful rectangle in the view’s own coordinate system. Never use a window’s frame coordinates as if they were view-local points.

Calling updateTrackingAreas() after changing the view’s geometry lets the subclass replace its prior tracking area. Remove an old area before adding a new one to avoid duplicate callbacks after repeated resizes. The area belongs to the view, so it can exist before the view joins a window, but its selected active condition still controls when events are delivered.

Select activity options deliberately

Use .activeInKeyWindow when hover behavior is meaningful only in the key window. Use .activeInActiveApp when another window in the active app should respond. .activeAlways is broader and should be chosen only when responding while the app is inactive is an intentional product requirement. For controls that must be focused before tracking, use the first-responder activity option.

Multiple options can combine tracking behaviors, but the activity semantics need to be understood as an interaction policy. A cursor shape that changes while a window is in the background may be confusing; a keyboard-driven editor may need cursor updates only while it owns focus. Test window activation, app switching, fullscreen, and multiple display arrangements rather than assuming the key-window condition is equivalent to app activity.

mouseEntered and mouseExited describe pointer transitions through an area. They are not button events, touch events, selection changes, or proof that an action is authorized. Use AppKit controls and gesture recognizers for their intended interactions. A hover highlight should not be the only indication that a control is selected or actionable.

Movement and cursor policy

Request mouseMoved only when the view needs continuous pointer coordinates. It can arrive at high frequency, so keep the handler lightweight and avoid triggering full-document layout or synchronous I/O. Coalesce drawing updates, and convert event positions using AppKit’s coordinate conversion APIs before mapping them into a model space.

Use cursor-update behavior for cursor policy rather than changing a global cursor from unrelated code. Keep the cursor state local to the view or control whose semantics it represents. If the pointer moves over a child view, define which view owns the interaction and avoid competing cursor changes from multiple ancestors.

When a drag crosses the area’s boundary, select the enabled-during-drag behavior only if the feature depends on enter/exit events during that drag. Otherwise, the extra events can produce redundant work. Implement a drag as its own state machine, because moving in and out of an area does not tell the application whether the drag was accepted or committed.

Hover visuals and model state

Keep hover presentation derived from local interaction state. Do not write hover state into persistent document data unless hovering is a real domain concept. When mouseExited occurs, clear transient visual state and request redraw of only the affected region. If an overlay is drawn in a different coordinate space than the tracking view, convert the pointer position deliberately.

For virtualized lists or canvases, track at the container or currently visible element granularity. Creating an area for every offscreen item can waste memory and complicate reuse. When a view represents a different model item, clear its prior hover state and update the tracking owner or user info so an event cannot be applied to the recycled item’s former identity.

An area can be synchronized with the visible rectangle, but clipping by parent views and nested scroll views still matters. If the parent clips content or the view transforms its drawing, verify the geometry against actual pointer behavior. Do not infer visibility from frame alone; use view visibility and conversion methods that reflect the current hierarchy.

Coordinate conversion and overlays

An NSEvent mouse location is commonly expressed in window coordinates, while the tracking area rectangle is in the receiving view’s coordinate system. Convert before hit-testing document elements. This distinction becomes visible when the window moves, a split view resizes, the view is flipped, or a scroll view changes its bounds origin. Avoid applying the same offset twice when the event is already converted.

If the hover target is an overlay or child view, define which layer owns the tracking area and which model object receives the event. A parent tracking the full canvas may receive movement over a child control. That can be useful for crosshair tools, but it can also cause the canvas to override a control’s cursor or selection affordance. Restrict areas to the view’s true responsibility and test overlap explicitly.

For accessibility, do not make a pointer-only tracking region the only way to reveal essential actions. Provide a keyboard route, VoiceOver-accessible control, or persistent menu action. Ensure hover contrast is not the only indicator of selection and does not reduce text contrast in light or dark appearance. A pointer leaving the window should clear transient visuals without changing saved document state.

Use performance instruments to observe whether a movement callback triggers excessive drawing or layout. A tracking area can deliver the desired events correctly while the view does too much work for every sample. In a dense canvas, convert the pointer into a lightweight model coordinate, identify the nearest candidate using a spatial index, and invalidate only the hover overlay rather than reconstructing all content.

Ownership and lifecycle

The tracking-area owner receives the event methods. Retaining a view as its own tracking-area owner is normal, but if a separate controller is the owner, ensure its lifetime covers the view and remove the area when ownership changes. Avoid closures or global registries that keep a transient window alive solely to receive hover events.

Remove or replace areas when their mode changes. A view that switches from editor to read-only mode may no longer need movement events; a hidden subview should not keep doing expensive pointer work. When a window closes, the view hierarchy should release its areas with the views. Explicitly reset transient state during teardown so stale “inside” state is not restored when the view is reused.

Do not depend on a precise one-to-one event count. Window activation, view geometry changes, cursor movement, and AppKit event tracking loops can alter which events are observed. Reconcile presentation from current interaction state after a lifecycle transition when necessary, and keep important commands available through keyboard or accessibility paths.

Validation matrix

Test resizing while the pointer is stationary, moving into and out of the view, scrolling under a stationary pointer, the view hidden by an ancestor, a non-key window, app switching, fullscreen, mouse drag, view reuse, and a changed tracking mode. Verify there are no duplicate callbacks after repeated layout passes and no hover state remains after the feature is hidden or closed.

Record the tracking options, view bounds, visible rect, window activation state, and item identity when debugging a hover defect. Avoid logging pointer coordinates unless there is a clear diagnostic need. A targeted debug overlay that draws the active region is often more useful than a stream of raw mouse events.

NSTrackingArea provides geometry-aware pointer observation for a view. The app chooses when the region is active, how events map to model state, what is drawn, and which alternate input paths remain available. Rebuild areas with current geometry and keep hover transient to make the behavior responsive and predictable.

Related:

Sources:

Comments