Skip to content

Managing Menu Bar Icon States in macOS

I often find myself staring at a static menu bar icon, wondering if the app is actually doing anything or if it just crashed in the background. Providing visual feedback is a great way to show users that your app is listening or processing a task without them having to open a window.

In this guide, I will show you how to manage different icon states for a macOS app, from simple idle wiggles to voice-triggered “big ears.”

  • Access to the macOS app source code (apps/macos).
  • The AppState and AppStateStore modules.
  • CritterIconRenderer for icon generation.

You can manage the menu bar icon through four primary states: Idle, Paused, Voice Trigger, and Working.

When your app hears a wake word, you want the icon to react immediately. I recommend using the earBoostActive state to scale the icon ears to 1.9x and add circular ear holes for better visibility.

To trigger this, call the voice wake detector:

// When the wake word is heard
AppState.triggerVoiceEars(ttl: nil)
// After 1s of silence to match the capture window
stopVoiceEars()

If your app is running a background task or an agent is active, you can trigger a “scurry” animation. This adds a faster leg wiggle and a small horizontal jiggle to the icon.

I suggest wrapping your work spans in defer blocks to ensure the animation resets even if the task fails:

AppStateStore.shared.setWorking(true)
defer {
AppStateStore.shared.setWorking(false)
}
// Perform your long-running task here

The base icon is drawn via CritterIconRenderer.makeIcon. You don’t need to change the frame size; it stays at 18×18 pt (rendered into a 36×36 px Retina store). The renderer handles these parameters:

  • blink: Normal eye animation.
  • legWiggle: Increased during “working” states.
  • earScale: Set to 1.9 during voice triggers.
  • earHoles: Toggled to true during voice triggers.

When the app is paused, the icon should stop all motion. You can achieve this by setting the status item to appearsDisabled.

The icon stays in the “Working” state forever This usually happens if a job hangs or the reset logic is skipped. Always use short TTLs (Time To Live) of less than 10 seconds. Ensure you call setWorking(false) inside a defer block so it runs regardless of how the function exits.

Voice ears are not appearing The voice trigger only fires from the in-app voice pipeline. There is no external CLI or broker toggle for this state. Check that your runtime is correctly calling AppState.triggerVoiceEars(ttl: nil) when the wake word is detected.

If you have more questions about specific implementation details, check out the AI Setup Assistant.

OpenClaw

OpenClaw Expert

Still stuck?

If this page didn't answer your case, ask OpenClaw Expert for step-by-step guidance.