Skip to content
macOSDeep Dive Published Updated 6 min readViews unavailable

NSAppearance on macOS: Dynamic Colors Without Dark-Mode Guesswork

Build appearance-aware AppKit interfaces with semantic colors, effectiveAppearance, asset variants, custom drawing, and reliable light-dark transition tests.

Dark Mode support is not a search-and-replace exercise that swaps white for black. AppKit appearance affects system controls, semantic colors, images, drawing, and inherited view behavior. A window can inherit the user’s current preference while one embedded view has an explicit appearance. Code that samples a color once and caches the resulting RGB value may therefore become incorrect after the user changes appearance. Reliable macOS interfaces preserve semantic intent and resolve concrete colors only in the correct appearance context.

This article focuses on NSAppearance, NSAppearanceCustomization, and AppKit drawing. User-created content, such as a document canvas, may have its own colors and must not be recolored merely because the surrounding chrome changes. AppKit’s appearance system is for the app’s interface, not a command to reinterpret the user’s document.

Inheritance and effective appearance

The system provides the default appearance. An app can override it, windows inherit from the app, and views inherit from their nearest ancestor with an explicit appearance. effectiveAppearance is the appearance that will actually be used to draw a receiver. Read that value when appearance-dependent drawing needs to be resolved; do not infer it from a system setting that ignores per-window or per-view overrides.

Most applications should not pin the entire app to Aqua or Dark Aqua. Let the user’s setting flow naturally and use semantic system colors or named color assets with light and dark variants. An explicit appearance is appropriate for a specialized region, such as a print preview or a view that must reproduce a particular user-created surface, but an override creates a responsibility to handle its descendants correctly.

import AppKit

final class ChartView: NSView {
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)

        // A semantic system color resolves according to the current context.
        NSColor.textBackgroundColor.setFill()
        dirtyRect.fill()

        let labelColor = NSColor.labelColor
        labelColor.setStroke()
        // Draw chart labels and outlines using semantic roles, not fixed RGBs.
    }
}

Semantic colors describe purpose: label, secondary label, separator, text background, control background, and so on. They are more resilient than a hand-picked gray because the system can adapt them to appearance, contrast, and platform conventions. A named asset catalog color can express brand-specific roles while still providing light and dark variants. Centralize custom palette roles so changing contrast or brand treatment does not require editing dozens of views.

Resolving colors for custom rendering

Custom drawing sometimes needs component values for a gradient, pixel buffer, or third-party graphics library. Resolve a dynamic NSColor against the view’s effectiveAppearance at the point you draw or build the rendering resource. If the appearance changes, discard the cached resolved color and rebuild the dependent resource. Do not call a helper that assumes the global current appearance is correct when rendering an offscreen view.

AppKit sets the current appearance while drawing its controls. For work outside an AppKit drawing callback, use performAsCurrentDrawingAppearance or an equivalent documented context method when a library consults the current drawing appearance. Keep the scope narrow. A global change to the current appearance can cause unrelated drawing or color conversions to resolve under the wrong theme.

func resolvedColor(_ color: NSColor, for view: NSView) -> NSColor {
    view.effectiveAppearance.performAsCurrentDrawingAppearance {
        color.usingColorSpace(.deviceRGB) ?? color
    }
}

The returned color is a snapshot for that resolution context, not a dynamic color that updates itself. Cache it only with the appearance identity and any color-space assumptions that produced it. If the output feeds a Core Graphics or Metal resource, invalidate and rebuild that resource when appearance changes. Also account for color profiles and display color management; device RGB components are not a universal perceptual space.

Observe transitions without hard-coding names

Views can respond when effective appearance changes. Use that lifecycle callback to invalidate drawing or derived resources. Avoid manually checking only for a single Dark Aqua name when the application supports increased contrast, custom appearances, or future system variants. If code needs to choose between two assets, use documented matching or trait behavior and keep a sensible fallback for appearances your app does not recognize.

Do not manually set the appearance on every child view merely to make tests deterministic. This can break inheritance and create a hierarchy that behaves differently from normal user settings. For a focused snapshot test, set appearance at the root of the test fixture and ensure the test also includes at least one inherited child and any intentionally overridden region.

System controls already adapt when semantic APIs are used. A common defect is a custom view that draws a fixed light background and then uses semantic foreground text, or an image whose white logo disappears against a dark surface. Audit contrast as a pair: foreground and background roles must remain distinguishable in each supported appearance. Use template images when monochrome tinting is intended, and preserve original colors for artwork that should not be tinted.

Appearance is not document content

A rich text editor may display the app’s chrome in Dark Mode while a white page remains white because that is the document’s configured page color. A code editor’s syntax palette may have user-selected themes. A graphics application must not invert a photograph when the system appearance changes. Keep interface palette, content palette, and accessibility contrast settings separate in the model.

When appearance changes, update only derived UI state. Do not serialize resolved RGB values over the document’s authored values. Persist semantic role identifiers or user-authored color values appropriate to the file format. If a document intentionally follows the system appearance, store that semantic policy explicitly and resolve it at display time.

Images, effects, and materials

Named image assets can provide variants for light and dark appearances. Use them for assets designed differently by theme, such as a diagram with labels or an icon whose contrast depends on its backdrop. A single template image may be better for a symbol that should inherit a tint. Avoid duplicating every image if the system can adapt it correctly; each variant creates another resource that must remain consistent.

Vibrancy and materials have additional compositing behavior. A view using an effect material depends on the content behind it, window state, and blending rules. Do not paint an opaque background above a visual-effect view if the intent is to expose the material. Test in active and inactive windows, over light and dark backgrounds, and when the window is partially transparent or resized.

Verification matrix

Test system light and dark settings, any supported increased-contrast option, a custom window appearance, an offscreen render, and an appearance change while a window is open. Inspect text, selection, disabled controls, separators, focus rings, chart series, shadows, and template images. Run visual tests on a physical or representative display profile if the product depends on precise color output. A unit test that checks the appearance name is not a substitute for inspecting actual contrast.

For custom views, ensure draw(_:) can be called repeatedly and does not mutate persistent model state. Verify that appearance change invalidation is cheap, drawing caches are rebuilt, and asynchronous image or GPU work cannot publish stale resources after a later appearance transition. Use generation numbers where rendering jobs can overlap.

The goal is not to make every pixel invert. It is to keep semantic UI readable and coherent while preserving the user’s authored content. Let appearance inherit by default, use semantic roles, resolve only when custom rendering requires concrete values, and test transitions rather than a single static screenshot.

Related:

Sources:

Comments