Crafting Toolbar Button States That Read Correctly in Both Light and Dark Mode
Message

Crafting Toolbar Button States That Read Correctly in Both Light and Dark Mode

In this Article

Five Postures, Two Appearances: The Rules Behind Every Toolbar Button

A toolbar button state matrix is the exact visual specification for how one control looks in each of its five interactive postures (rest, hover, pressed, selected, disabled), multiplied across Aqua and DarkAqua and across active and inactive windows. Five states, two appearances, two focus conditions: 20 cells per control.

Most macOS utility apps specify perhaps four of those cells and improvise the rest.

The defects show up in predictable places. Audits of utility-app toolbars keep turning up the same three: hover overlays hardcoded as hex grays in light mode and pasted unchanged into dark mode, selected toggles that look identical to hovered buttons, and an inactive window state that nobody specified at all. Our UI engineering and design team stopped patching apps one by one and wrote a single set of rules instead. Auditing one app this way takes 3 to 5 working days, covering 6 to 12 toolbar items against all 20 cells, with a re-check whenever an OS point release changes how materials render.

The rules, stated upfront:

  • No hardcoded hex for state overlays. Use semantic fills. NSColor.quaternaryLabelColor works as a glyph-adjacent overlay; tertiarySystemFill and quaternarySystemFill are available from macOS 14. For macOS 13 and earlier, labelColor with an explicit alpha multiplier is the fallback.
  • Invert luminance between appearances. A press darkens the surface in light mode and brightens it, or deepens the translucency, in dark mode.
  • Specify inactive windows explicitly. They are half the grid.

Scope matters here. Everything below applies to standard NSWindow toolbars, including SwiftUI toolbars bridged into AppKit. For the platform baseline, start with the Apple Human Interface Guidelines for macOS toolbars.

Darken in Aqua, Lift in DarkAqua: Fills and Glyphs per State

Why can't dark mode just reuse the light-mode overlay at a different gray? Because a dark overlay laid on an already-dark material registers as almost no change. The eye reads lightness shifts relative to the surface, so the direction of the shift has to flip with the appearance.

Rest and hover

Rest carries no background fill in either appearance, and the glyph sits at secondaryLabelColor. The toolbar should read as calm until someone reaches for it. Hover adds black at roughly 5 to 6% opacity in light mode, or white at roughly 7 to 8% in dark mode, and the glyph steps up from secondary to primary label color. That glyph step does more work than the fill.

Pressed needs to feel physical

Pressed is the tactile state: black at somewhere around 12% in light mode, white at 14% more or less in dark mode. The SF Symbol stays at primary label contrast. Designers often dim the glyph on press to suggest depth, and users read that dimming as a disabled flicker.

The hover-versus-selected collision

Here is where most toolbars quietly fail. A selected toggle at rest and an unselected button under the cursor end up with nearly the same fill. The instinctive fix, more opacity on selected, just pushes it into pressed territory.

Treat it as a separation problem first and a color problem second. Selected keeps a modest fill (black around 8 to 9% light, white around 11 to 12% dark) and then gains two cues that live off the background: a 1px inner hairline (0.5pt on Retina) at about 10 to 15% label-color opacity, and either a semibold symbol weight or the accent color on the glyph. Hover never gets a border, so the two states stop competing.

The hover-versus-selected collision

Disabled

No fill, glyph at tertiaryLabelColor. AppKit handles this when you set isEnabled = false. SwiftUI does not: read @Environment(\.isEnabled) inside your ButtonStyle, or the style will happily render the enabled appearance.

Hover Wiring Gotcha

A custom NSButton subclass gets no hover events for free. It needs an NSTrackingArea with .mouseEnteredAndExited and .activeInKeyWindow. SwiftUI's .onHover is the equivalent, but it fires only when the view actually receives pointer events.

When the Wallpaper Leaks Through the Toolbar

Contrast on a translucent toolbar is a moving target. NSVisualEffectView, along with SwiftUI's .ultraThinMaterial, .bar, and header-style materials, blends blurred content from behind the window into the toolbar background. Drag the window from a white browser to a neon wallpaper and the effective value under your 6% hover fill changes with it.

When the Wallpaper Leaks Through the Toolbar

So testing becomes a visual protocol rather than a calculator exercise. A useful backdrop set per pass:

  1. A pure white browser window.
  2. A near-black terminal.
  3. A saturated neon gradient wallpaper.
  4. A mid-gray photographic wallpaper.
  5. A busy high-frequency image, such as foliage or a city grid.

Running all five backdrops across both appearances and both focus states takes about 40 to 60 minutes per toolbar. The WCAG 1.4.11 target of 3:1 for non-text UI component boundaries makes a sensible reference for state boundaries, though on blurred material the measured ratio drifts with the wallpaper. One eyedropper reading proves nothing.

Then check hardware. Test on at least one Retina panel and one non-Retina external display at 1x, because a 0.5pt hairline that renders cleanly at 2x can vanish or smear into a 1px gray blur at 1x. That hairline carries the whole selected-state distinction, so losing it is serious.

The sixth dimension: unfocused windows

AppKit controls switch automatically when a window resigns key status. In SwiftUI, read @Environment(\.controlActiveState) (deprecated in macOS 15) or @Environment(\.appearsActive) on macOS 15 and later. Inactive glyphs drop toward tertiary label color and fills reduce proportionally.

The trap: tertiary glyph plus reduced fill is also, roughly, the disabled recipe. Selected toggles in an inactive window must keep their hairline at reduced opacity rather than losing it, so they never collapse into the disabled look.

Where This Grid Stops

These checks assume a standard NSWindow toolbar over a system material. Full-screen HUD panels and always-dark overlay windows run through a separate material pipeline and need their own matrix. A HUD never resigns key status the way a document window does, so the inactive columns simply don't apply to it.

Laying Out the 20-Cell Grid So Nobody Samples a Screenshot

The handoff format the team abandoned is instructive. It used to ship as annotated screenshots, one per state. Developers sampled colors straight from the images, which reintroduced exactly the hardcoded values the matrix existed to eliminate. Screenshots now appear only as confirmation beside a token, never as the source of truth.

The grid itself: rows are the five interactive states, columns are grouped Light-Active, Light-Inactive, Dark-Active, Dark-Inactive. In Figma, build it as one component with three variant properties (state, appearance, focus), so every cell is an instance rather than a hand-drawn rectangle. Change the hover alpha once and all four columns update.

Token names follow surface.component.property.state:

  • toolbar.button.bg.hover
  • toolbar.button.border.selected
  • toolbar.glyph.selected, with inactive variants suffixed as toolbar.glyph.selected.inactive

Each token maps either to an Xcode Asset Catalog color set with Any and Dark appearance slots, or to a documented NSColor semantic plus its alpha multiplier, such as labelColor.withAlphaComponent(0.12) for the light-mode pressed fill.

Motion belongs in the same document. Pressed appears at 0ms on mouse-down with no transition. Hover entry is instant or under 50ms; hover exit uses a 100ms ease-out, give or take. Selection toggles crossfade over 120 to 150ms, long enough to register, short enough to avoid sluggishness. Inactive transitions follow system timing and get no separate animation, so the toolbar dims in step with native controls.

Tokens Before Pixels

If a developer can build a cell without opening an image, the spec is doing its job. Every cell needs a token name and an alpha value; the picture is optional.

Toggle Inspector, Cell by Cell: A Copyable sidebar.right Spec

The Toggle Inspector button makes the best test case because it hits every hard cell. It is a persistent toggle, so selected and selected-plus-hover both occur. It sits in the trailing toolbar group, where material bleed is heaviest. And it has to stay legible when the window sits behind another app.

Container: something like 6pt continuous corner radius, about 28pt tall, width fitted to the glyph plus horizontal padding. Glyph: SF Symbol sidebar.right at the toolbar's standard symbol size, hierarchical rendering.

StateFill (Light / Dark)Inner hairlineGlyph
RestNone / NoneNoneSecondary hierarchical level
HoverBlack 6% / White 8%NonePrimary level
PressedBlack 12% / White 14%NonePrimary, no opacity reduction
SelectedBlack 9% / White 12%0.5pt at about 12% label opacityAccent-tinted primary layer
Selected + HoverSelected fill raised about 3 alpha pointsUnchangedAccent glyph unchanged
DisabledNone / NoneSuppressed, even if toggled onTertiary label color

The selected-plus-hover cell is the one teams get wrong. Its fill climbs toward the 12% and 14% pressed values directly above it, but the hairline and accent glyph keep it distinct, because pressed carries neither.

Resolution order decides which recipe applies. The first matching condition wins:

enum InspectorState { case disabled, pressed, selectedHover, selected, hover, rest }

func resolve(enabled: Bool, pressed: Bool,
             selected: Bool, hovering: Bool) -> InspectorState {
    if !enabled { return .disabled }
    if pressed { return .pressed }
    if selected && hovering { return .selectedHover }
    if selected { return .selected }
    if hovering { return .hover }
    return .rest
}

In SwiftUI, feed it isEnabled from the environment, isPressed from the ButtonStyle configuration, selection from a bound Bool, and hover from .onHover state. In AppKit, use NSButton with button type .pushOnPushOff and override updateLayer or draw only for the hover overlay; AppKit already manages disabled and inactive dimming.

Applying the same table to a secondary filter toggle—such as a line.3.horizontal.decrease.circle button sitting next to a search field—exposes where the inactive and selected states collide. With the filter active and the cursor hovering over the button in DarkAqua, resolve returns .selectedHover: the background steps from a 12% white fill up to 15%, the 0.5pt inner hairline holds at 12% label opacity, and the glyph keeps its accent tint. When the user clicks over to Finder and the window resigns key status, the hover state drops out, the glyph falls back from the accent color to tertiaryLabelColor, and the fill dims proportionally, leaving that 0.5pt inner hairline as the sole indicator across a blurred neon wallpaper that the view is still filtered rather than disabled.

References

  • W3C, Web Content Accessibility Guidelines 2.1, Success Criterion 1.4.11 Non-text Contrast.

Comments

No comments so far.

Join the Discussion